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

+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