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 1 import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions"; 2 2 import { SessionManager } from "./session-manager"; 3 + import { getDbClient } from "./db"; 3 4 import cookie from "cookie"; 4 5 5 6 export const handler: Handler = async ( ··· 57 58 const { agent, did: userDid } = 58 59 await SessionManager.getAgentForSession(sessionId); 59 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 + 60 96 // Follow all users 61 97 const results = []; 62 98 let consecutiveErrors = 0; 63 99 const MAX_CONSECUTIVE_ERRORS = 3; 100 + const sql = getDbClient(); 64 101 65 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 + 66 127 try { 67 128 await agent.api.com.atproto.repo.createRecord({ 68 129 repo: userDid, ··· 77 138 results.push({ 78 139 did, 79 140 success: true, 141 + alreadyFollowing: false, 80 142 error: null, 81 143 }); 82 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 + 83 157 // Reset error counter on success 84 158 consecutiveErrors = 0; 85 159 } catch (error) { ··· 88 162 results.push({ 89 163 did, 90 164 success: false, 165 + alreadyFollowing: false, 91 166 error: error instanceof Error ? error.message : "Follow failed", 92 167 }); 93 168 ··· 112 187 113 188 const successCount = results.filter((r) => r.success).length; 114 189 const failCount = results.filter((r) => !r.success).length; 190 + const alreadyFollowingCount = results.filter( 191 + (r) => r.alreadyFollowing, 192 + ).length; 115 193 116 194 return { 117 195 statusCode: 200, ··· 124 202 total: dids.length, 125 203 succeeded: successCount, 126 204 failed: failCount, 205 + alreadyFollowing: alreadyFollowingCount, 127 206 results, 128 207 }), 129 208 };
+51
netlify/functions/batch-search-actors.ts
··· 148 148 }); 149 149 } 150 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 + 151 202 return { 152 203 statusCode: 200, 153 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 56 matchScore: number, 57 57 postCount: number, 58 58 followerCount: number, 59 + followStatus?: Record<string, boolean>, 59 60 ): Promise<number> { 60 61 const sql = getDbClient(); 61 62 const result = await sql` 62 63 INSERT INTO atproto_matches ( 63 64 source_account_id, atproto_did, atproto_handle, 64 65 atproto_display_name, atproto_avatar, match_score, 65 - post_count, follower_count 66 + post_count, follower_count, follow_status 66 67 ) 67 68 VALUES ( 68 69 ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 69 70 ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 70 - ${postCount || 0}, ${followerCount || 0} 71 + ${postCount || 0}, ${followerCount || 0}, ${JSON.stringify(followStatus || {})} 71 72 ) 72 73 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 73 74 atproto_handle = ${atprotoHandle}, ··· 76 77 match_score = ${matchScore}, 77 78 post_count = ${postCount}, 78 79 follower_count = ${followerCount}, 80 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})}, 79 81 last_verified = NOW() 80 82 RETURNING id 81 83 `; ··· 192 194 matchScore: number; 193 195 postCount?: number; 194 196 followerCount?: number; 197 + followStatus?: Record<string, boolean>; 195 198 }>, 196 199 ): Promise<Map<string, number>> { 197 200 const sql = getDbClient(); ··· 207 210 const matchScore = matches.map((m) => m.matchScore); 208 211 const postCount = matches.map((m) => m.postCount || 0); 209 212 const followerCount = matches.map((m) => m.followerCount || 0); 213 + const followStatus = matches.map((m) => JSON.stringify(m.followStatus || {})); 210 214 211 215 const result = await sql` 212 216 INSERT INTO atproto_matches ( 213 217 source_account_id, atproto_did, atproto_handle, 214 218 atproto_display_name, atproto_avatar, atproto_description, 215 - match_score, post_count, follower_count 219 + match_score, post_count, follower_count, follow_status 216 220 ) 217 221 SELECT * FROM UNNEST( 218 222 ${sourceAccountId}::integer[], ··· 223 227 ${atprotoDescription}::text[], 224 228 ${matchScore}::integer[], 225 229 ${postCount}::integer[], 226 - ${followerCount}::integer[] 230 + ${followerCount}::integer[], 231 + ${followStatus}::jsonb[] 227 232 ) AS t( 228 233 source_account_id, atproto_did, atproto_handle, 229 234 atproto_display_name, atproto_avatar, match_score, 230 - post_count, follower_count 235 + post_count, follower_count, follow_status 231 236 ) 232 237 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 233 238 atproto_handle = EXCLUDED.atproto_handle, ··· 237 242 match_score = EXCLUDED.match_score, 238 243 post_count = EXCLUDED.post_count, 239 244 follower_count = EXCLUDED.follower_count, 245 + follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || EXCLUDED.follow_status, 240 246 last_verified = NOW() 241 247 RETURNING id, source_account_id, atproto_did 242 248 `;
+7 -3
netlify/functions/db.ts
··· 108 108 found_at TIMESTAMP DEFAULT NOW(), 109 109 last_verified TIMESTAMP, 110 110 is_active BOOLEAN DEFAULT TRUE, 111 + follow_status JSONB DEFAULT '{}', 112 + last_follow_check TIMESTAMP, 111 113 UNIQUE(source_account_id, atproto_did) 112 114 ) 113 115 `; ··· 143 145 ) 144 146 `; 145 147 146 - // ==================== ENHANCED INDEXES FOR PHASE 2 ==================== 147 - 148 148 // Existing indexes 149 149 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`; 150 150 await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`; ··· 156 156 await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`; 157 157 await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 158 158 159 - // NEW: Enhanced indexes for common query patterns 159 + // ======== Enhanced indexes for common query patterns ========= 160 160 161 161 // For sorting 162 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 183 184 184 // For bulk operations - normalized username lookups 185 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)`; 186 190 187 191 console.log("✅ Database indexes created/verified"); 188 192 }
+17 -14
netlify/functions/get-upload-details.ts
··· 82 82 // Fetch paginated results with optimized query 83 83 const results = await sql` 84 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, 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, 99 101 -- Calculate if this is a new match (found after upload creation) 100 102 CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 101 103 FROM user_source_follows usf ··· 153 155 foundAt: row.found_at, 154 156 followed: row.followed || false, 155 157 dismissed: row.dismissed || false, 158 + followStatus: row.follow_status || {}, 156 159 }); 157 160 } 158 161 });
+4
src/App.tsx
··· 11 11 import { useFileUpload } from "./hooks/useFileUpload"; 12 12 import { useTheme } from "./hooks/useTheme"; 13 13 import Firefly from "./components/Firefly"; 14 + import { ATPROTO_APPS } from "./constants/atprotoApps"; 14 15 import { DEFAULT_SETTINGS } from "./types/settings"; 15 16 import type { UserSettings } from "./types/settings"; 16 17 ··· 86 87 setCurrentStep("loading"); 87 88 88 89 const uploadId = crypto.randomUUID(); 90 + const followLexicon = 91 + ATPROTO_APPS[currentDestinationAppId]?.followLexicon; 89 92 90 93 searchAllUsers(initialResults, setStatusMessage, () => { 91 94 setCurrentStep("results"); ··· 302 305 isFollowing={isFollowing} 303 306 currentStep={currentStep} 304 307 sourcePlatform={currentPlatform} 308 + destinationAppId={currentDestinationAppId} 305 309 reducedMotion={reducedMotion} 306 310 isDark={isDark} 307 311 onToggleTheme={toggleTheme}
+18 -7
src/components/SearchResultCard.tsx
··· 7 7 UserCheck, 8 8 } from "lucide-react"; 9 9 import { PLATFORMS } from "../constants/platforms"; 10 + import { ATPROTO_APPS } from "../constants/atprotoApps"; 10 11 import type { SearchResult } from "../types"; 12 + import type { AtprotoAppId } from "../types/settings"; 11 13 12 14 interface SearchResultCardProps { 13 15 result: SearchResult; ··· 16 18 onToggleExpand: () => void; 17 19 onToggleMatchSelection: (did: string) => void; 18 20 sourcePlatform: string; 21 + destinationAppId?: AtprotoAppId; 19 22 } 20 23 21 24 export default function SearchResultCard({ ··· 25 28 onToggleExpand, 26 29 onToggleMatchSelection, 27 30 sourcePlatform, 31 + destinationAppId = "bluesky", 28 32 }: SearchResultCardProps) { 29 33 const displayMatches = isExpanded 30 34 ? result.atprotoMatches ··· 32 36 const hasMoreMatches = result.atprotoMatches.length > 1; 33 37 const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok; 34 38 39 + // Get current follow lexicon 40 + const currentApp = ATPROTO_APPS[destinationAppId]; 41 + const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow"; 42 + 35 43 return ( 36 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"> 37 45 {/* Source User */} ··· 64 72 ) : ( 65 73 <div className=""> 66 74 {displayMatches.map((match) => { 67 - const isFollowed = match.followed; 75 + // Check follow status for current lexicon 76 + const isFollowedInCurrentApp = 77 + match.followStatus?.[currentLexicon] ?? match.followed ?? false; 68 78 const isSelected = result.selectedMatches?.has(match.did); 79 + 69 80 return ( 70 81 <div 71 82 key={match.did} ··· 133 144 {/* Select/Follow Button */} 134 145 <button 135 146 onClick={() => onToggleMatchSelection(match.did)} 136 - disabled={isFollowed} 147 + disabled={isFollowedInCurrentApp} 137 148 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" 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" 140 151 : isSelected 141 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" 142 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" 143 154 }`} 144 155 title={ 145 - isFollowed 146 - ? "Already followed" 156 + isFollowedInCurrentApp 157 + ? `Already following on ${currentApp?.name || "this app"}` 147 158 : isSelected 148 159 ? "Selected to follow" 149 160 : "Select to follow" 150 161 } 151 162 > 152 - {isFollowed ? ( 163 + {isFollowedInCurrentApp ? ( 153 164 <Check className="w-4 h-4" /> 154 165 ) : isSelected ? ( 155 166 <UserCheck className="w-4 h-4" />
+72 -7
src/hooks/useFollows.ts
··· 13 13 destinationAppId: AtprotoAppId, 14 14 ) { 15 15 const [isFollowing, setIsFollowing] = useState(false); 16 + const [isCheckingFollowStatus, setIsCheckingFollowStatus] = useState(false); 16 17 17 18 async function followSelectedUsers( 18 19 onUpdate: (message: string) => void, ··· 31 32 return; 32 33 } 33 34 34 - // Follow users 35 + // Get selected users 35 36 const selectedUsers = searchResults.flatMap((result, resultIndex) => 36 37 result.atprotoMatches 37 38 .filter((match) => result.selectedMatches?.has(match.did)) ··· 45 46 return; 46 47 } 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 + 48 100 setIsFollowing(true); 49 101 onUpdate( 50 - `Following ${selectedUsers.length} users on ${destinationName}...`, 102 + `Following ${usersToFollow.length} users on ${destinationName}...`, 51 103 ); 52 104 let totalFollowed = 0; 53 105 let totalFailed = 0; ··· 55 107 try { 56 108 const { BATCH_SIZE } = FOLLOW_CONFIG; 57 109 58 - for (let i = 0; i < selectedUsers.length; i += BATCH_SIZE) { 59 - const batch = selectedUsers.slice(i, i + BATCH_SIZE); 110 + for (let i = 0; i < usersToFollow.length; i += BATCH_SIZE) { 111 + const batch = usersToFollow.slice(i, i + BATCH_SIZE); 60 112 const dids = batch.map((user) => user.did); 61 113 62 114 try { ··· 77 129 atprotoMatches: searchResult.atprotoMatches.map( 78 130 (match) => 79 131 match.did === result.did 80 - ? { ...match, followed: true } 132 + ? { 133 + ...match, 134 + followed: true, // Backward compatibility 135 + followStatus: { 136 + ...match.followStatus, 137 + [followLexicon]: true, 138 + }, 139 + } 81 140 : match, 82 141 ), 83 142 } ··· 89 148 }); 90 149 91 150 onUpdate( 92 - `Followed ${totalFollowed} of ${selectedUsers.length} users`, 151 + `Followed ${totalFollowed} of ${usersToFollow.length} users`, 93 152 ); 94 153 } catch (error) { 95 154 totalFailed += batch.length; ··· 99 158 // Rate limit handling is in the backend 100 159 } 101 160 102 - const finalMsg = `Successfully followed ${totalFollowed} users${totalFailed > 0 ? `. ${totalFailed} failed.` : ""}`; 161 + const finalMsg = 162 + `Successfully followed ${totalFollowed} users` + 163 + (alreadyFollowingCount > 0 164 + ? ` (${alreadyFollowingCount} already followed)` 165 + : "") + 166 + (totalFailed > 0 ? `. ${totalFailed} failed.` : ""); 103 167 onUpdate(finalMsg); 104 168 } catch (error) { 105 169 console.error("Batch follow error:", error); ··· 111 175 112 176 return { 113 177 isFollowing, 178 + isCheckingFollowStatus, 114 179 followSelectedUsers, 115 180 }; 116 181 }
+5 -1
src/hooks/useSearch.ts
··· 43 43 resultsToSearch: SearchResult[], 44 44 onProgressUpdate: (message: string) => void, 45 45 onComplete: () => void, 46 + followLexicon?: string, 46 47 ) { 47 48 if (!session || resultsToSearch.length === 0) return; 48 49 ··· 80 81 ); 81 82 82 83 try { 83 - const data = await apiClient.batchSearchActors(usernames); 84 + const data = await apiClient.batchSearchActors( 85 + usernames, 86 + followLexicon, 87 + ); 84 88 85 89 // Reset error counter on success 86 90 consecutiveErrors = 0;
+20
src/lib/apiClient/mockApiClient.ts
··· 29 29 description: `Mock profile for ${username}`, 30 30 postCount: Math.floor(Math.random() * 1000), 31 31 followerCount: Math.floor(Math.random() * 5000), 32 + followStatus: { 33 + "app.bsky.graph.follow": Math.random() < 0.3, // 30% already following 34 + }, 32 35 })); 33 36 } 34 37 ··· 70 73 localStorage.removeItem("mock_uploads"); 71 74 }, 72 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 + 73 92 async getUploads(): Promise<{ uploads: any[] }> { 74 93 await delay(300); 75 94 console.log("[MOCK] Getting uploads"); ··· 110 129 111 130 async batchSearchActors( 112 131 usernames: string[], 132 + followLexicon?: string, 113 133 ): Promise<{ results: BatchSearchResult[] }> { 114 134 await delay(800); // Simulate API delay 115 135 console.log("[MOCK] Searching for:", usernames);
+38 -3
src/lib/apiClient/realApiClient.ts
··· 247 247 return { results: allResults }; 248 248 }, 249 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 + 250 282 // Search Operations 251 283 async batchSearchActors( 252 284 usernames: string[], 285 + followLexicon?: string, 253 286 ): Promise<{ results: BatchSearchResult[] }> { 254 287 // Create cache key from sorted usernames (so order doesn't matter) 255 - const cacheKey = `search-${usernames.slice().sort().join(",")}`; 288 + const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`; 256 289 const cached = cache.get<any>(cacheKey, 10 * 60 * 1000); 257 290 if (cached) { 258 291 console.log( ··· 267 300 method: "POST", 268 301 credentials: "include", 269 302 headers: { "Content-Type": "application/json" }, 270 - body: JSON.stringify({ usernames }), 303 + body: JSON.stringify({ usernames, followLexicon }), 271 304 }); 272 305 273 306 if (!res.ok) { ··· 291 324 total: number; 292 325 succeeded: number; 293 326 failed: number; 327 + alreadyFollowing: number; 294 328 results: BatchFollowResult[]; 295 329 }> { 296 330 const res = await fetch("/.netlify/functions/batch-follow-users", { ··· 306 340 307 341 const data = await res.json(); 308 342 309 - // Invalidate uploads cache after following 343 + // Invalidate caches after following 310 344 cache.invalidate("uploads"); 311 345 cache.invalidatePattern("upload-details"); 346 + cache.invalidatePattern("follow-status"); 312 347 313 348 return data; 314 349 },
+4
src/pages/Results.tsx
··· 2 2 import { PLATFORMS } from "../constants/platforms"; 3 3 import AppHeader from "../components/AppHeader"; 4 4 import SearchResultCard from "../components/SearchResultCard"; 5 + import type { AtprotoAppId } from "../types/settings"; 5 6 6 7 interface atprotoSession { 7 8 did: string; ··· 41 42 isFollowing: boolean; 42 43 currentStep: string; 43 44 sourcePlatform: string; 45 + destinationAppId: AtprotoAppId; 44 46 reducedMotion?: boolean; 45 47 isDark?: boolean; 46 48 onToggleTheme?: () => void; ··· 63 65 isFollowing, 64 66 currentStep, 65 67 sourcePlatform, 68 + destinationAppId, 66 69 reducedMotion = false, 67 70 isDark = false, 68 71 onToggleTheme, ··· 185 188 onToggleMatchSelection(originalIndex, did) 186 189 } 187 190 sourcePlatform={sourcePlatform} 191 + destinationAppId={destinationAppId} 188 192 /> 189 193 ); 190 194 })}
+3 -1
src/types/index.ts
··· 21 21 avatar?: string; 22 22 matchScore: number; 23 23 description?: string; 24 - followed?: boolean; 24 + followed?: boolean; // DEPRECATED - kept for backward compatibility 25 + followStatus?: Record<string, boolean>; 25 26 postCount?: number; 26 27 followerCount?: number; 27 28 foundAt?: string; ··· 62 63 export interface BatchFollowResult { 63 64 did: string; 64 65 success: boolean; 66 + alreadyFollowing?: boolean; 65 67 error: string | null; 66 68 } 67 69