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

add user ranking for search results

+179 -49
+6 -1
netlify/functions/batch-search-actors.ts
··· 116 116 else if (normalizedDisplayName.includes(normalizedUsername)) score = 40; 117 117 else if (normalizedUsername.includes(normalizedHandle)) score = 30; 118 118 119 - return { ...actor, matchScore: score }; 119 + return { 120 + ...actor, 121 + matchScore: score, 122 + postCount: actor.postCount || 0, 123 + followerCount: actor.followerCount || 0 124 + }; 120 125 }) 121 126 .filter((actor: any) => actor.matchScore > 0) 122 127 .sort((a: any, b: any) => b.matchScore - a.matchScore)
+22 -6
netlify/functions/db-helpers.ts
··· 53 53 atprotoHandle: string, 54 54 atprotoDisplayName: string | undefined, 55 55 atprotoAvatar: string | undefined, 56 - matchScore: number 56 + matchScore: number, 57 + postCount: number, 58 + followerCount: number 57 59 ): Promise<number> { 58 60 const sql = getDbClient(); 59 61 const result = await sql` 60 62 INSERT INTO atproto_matches ( 61 63 source_account_id, atproto_did, atproto_handle, 62 - atproto_display_name, atproto_avatar, match_score 64 + atproto_display_name, atproto_avatar, match_score, 65 + post_count, follower_count 63 66 ) 64 67 VALUES ( 65 68 ${sourceAccountId}, ${atprotoDid}, ${atprotoHandle}, 66 - ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore} 69 + ${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore}, 70 + ${postCount || 0}, ${followerCount || 0} 67 71 ) 68 72 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 69 73 atproto_handle = ${atprotoHandle}, 70 74 atproto_display_name = ${atprotoDisplayName || null}, 71 75 atproto_avatar = ${atprotoAvatar || null}, 72 76 match_score = ${matchScore}, 77 + post_count = ${postCount}, 78 + follower_count = ${followerCount}, 73 79 last_verified = NOW() 74 80 RETURNING id 75 81 `; ··· 185 191 atprotoDisplayName?: string; 186 192 atprotoAvatar?: string; 187 193 matchScore: number; 194 + postCount?: number; 195 + followerCount?: number; 188 196 }> 189 197 ): Promise<Map<string, number>> { 190 198 const sql = getDbClient(); ··· 197 205 const atprotoDisplayName = matches.map(m => m.atprotoDisplayName || null) 198 206 const atprotoAvatar = matches.map(m => m.atprotoAvatar || null) 199 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) 200 210 201 211 const result = await sql` 202 212 INSERT INTO atproto_matches ( 203 213 source_account_id, atproto_did, atproto_handle, 204 - atproto_display_name, atproto_avatar, match_score 214 + atproto_display_name, atproto_avatar, match_score, 215 + post_count, follower_count 205 216 ) 206 217 SELECT * FROM UNNEST( 207 218 ${sourceAccountId}::integer[], ··· 209 220 ${atprotoHandle}::text[], 210 221 ${atprotoDisplayName}::text[], 211 222 ${atprotoAvatar}::text[], 212 - ${matchScore}::integer[] 223 + ${matchScore}::integer[], 224 + ${postCount}::integer[], 225 + ${followerCount}::integer[] 213 226 ) AS t( 214 227 source_account_id, atproto_did, atproto_handle, 215 - atproto_display_name, atproto_avatar, match_score 228 + atproto_display_name, atproto_avatar, match_score, 229 + post_count, follower_count 216 230 ) 217 231 ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET 218 232 atproto_handle = EXCLUDED.atproto_handle, 219 233 atproto_display_name = EXCLUDED.atproto_display_name, 220 234 atproto_avatar = EXCLUDED.atproto_avatar, 221 235 match_score = EXCLUDED.match_score, 236 + post_count = EXCLUDED.post_count, 237 + follower_count = EXCLUDED.follower_count, 222 238 last_verified = NOW() 223 239 RETURNING id, source_account_id, atproto_did 224 240 `;
+5
netlify/functions/db.ts
··· 100 100 atproto_handle TEXT NOT NULL, 101 101 atproto_display_name TEXT, 102 102 atproto_avatar TEXT, 103 + post_count INTEGER, 104 + follower_count INTEGER, 103 105 match_score INTEGER NOT NULL, 104 106 found_at TIMESTAMP DEFAULT NOW(), 105 107 last_verified TIMESTAMP, ··· 153 155 await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`; 154 156 155 157 // NEW: Enhanced indexes for common query patterns 158 + 159 + // For sorting 160 + 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)`; 156 161 157 162 // For session lookups (most frequent query) 158 163 await sql`CREATE INDEX IF NOT EXISTS idx_user_sessions_did ON user_sessions(did)`;
+33 -9
netlify/functions/get-upload-details.ts
··· 84 84 am.atproto_display_name, 85 85 am.atproto_avatar, 86 86 am.match_score, 87 + am.post_count, 88 + am.follower_count, 89 + am.found_at, 87 90 ums.followed, 88 - ums.dismissed 91 + ums.dismissed, 92 + -- Calculate if this is a new match (found after upload creation) 93 + CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match 89 94 FROM user_source_follows usf 90 95 JOIN source_accounts sa ON usf.source_account_id = sa.id 91 - LEFT JOIN atproto_matches am ON sa.id = am.source_account_id 96 + JOIN user_uploads uu ON usf.upload_id = uu.upload_id 97 + LEFT JOIN atproto_matches am ON sa.id = am.source_account_id AND am.is_active = true 92 98 LEFT JOIN user_match_status ums ON am.id = ums.atproto_match_id AND ums.did = ${userSession.did} 93 99 WHERE usf.upload_id = ${uploadId} 94 - ORDER BY sa.source_username 100 + ORDER BY 101 + -- 1. Users with matches first 102 + CASE WHEN am.atproto_did IS NOT NULL THEN 0 ELSE 1 END, 103 + -- 2. New matches (found after initial upload) 104 + is_new_match DESC, 105 + -- 3. Highest post count 106 + am.post_count DESC NULLS LAST, 107 + -- 4. Highest follower count 108 + am.follower_count DESC NULLS LAST, 109 + -- 5. Username as tiebreaker 110 + sa.source_username 95 111 LIMIT ${pageSize} 96 112 OFFSET ${offset} 97 113 `; 98 114 99 115 // Group results by source username 100 - const groupedResults: any = {}; 116 + const groupedResults = new Map<string, any>(); 101 117 102 118 (results as any[]).forEach((row: any) => { 103 119 const username = row.source_username; 104 120 105 - if (!groupedResults[username]) { 106 - groupedResults[username] = { 107 - tiktokUser: { 121 + // Get or create the entry for this username 122 + let userResult = groupedResults.get(username); 123 + 124 + if (!userResult) { 125 + userResult = { 126 + sourceUser: { 108 127 username: username, 109 128 date: row.source_date || '', 110 129 }, 111 130 atprotoMatches: [], 112 131 }; 132 + groupedResults.set(username, userResult); // Add to map, this preserves the order 113 133 } 114 134 135 + // Add the match (if it exists) to the array 115 136 if (row.atproto_did) { 116 - groupedResults[username].atprotoMatches.push({ 137 + userResult.atprotoMatches.push({ 117 138 did: row.atproto_did, 118 139 handle: row.atproto_handle, 119 140 displayName: row.atproto_display_name, 120 141 avatar: row.atproto_avatar, 121 142 matchScore: row.match_score, 143 + postCount: row.post_count, 144 + followerCount: row.follower_count, 145 + foundAt: row.found_at, 122 146 followed: row.followed || false, 123 147 dismissed: row.dismissed || false, 124 148 }); 125 149 } 126 150 }); 127 151 128 - const searchResults = Object.values(groupedResults); 152 + const searchResults = Array.from(groupedResults.values()); 129 153 130 154 return { 131 155 statusCode: 200,
+12 -6
netlify/functions/save-results.ts
··· 12 12 import { getDbClient } from './db'; 13 13 14 14 interface SearchResult { 15 - tiktokUser: { 15 + sourceUser: { 16 16 username: string; 17 17 date: string; 18 18 }; ··· 22 22 displayName?: string; 23 23 avatar?: string; 24 24 matchScore: number; 25 + postCount: number; 26 + followerCount: number; 25 27 }>; 26 28 isSearching?: boolean; 27 29 error?: string; ··· 110 112 ); 111 113 112 114 // BULK OPERATION 1: Create all source accounts at once 113 - const allUsernames = results.map(r => r.tiktokUser.username); 115 + const allUsernames = results.map(r => r.sourceUser.username); 114 116 const sourceAccountIdMap = await bulkCreateSourceAccounts(sourcePlatform, allUsernames); 115 117 116 118 // BULK OPERATION 2: Link all users to source accounts 117 119 const links = results.map(result => { 118 - const normalized = result.tiktokUser.username.toLowerCase().replace(/[._-]/g, ''); 120 + const normalized = result.sourceUser.username.toLowerCase().replace(/[._-]/g, ''); 119 121 const sourceAccountId = sourceAccountIdMap.get(normalized); 120 122 return { 121 123 sourceAccountId: sourceAccountId!, 122 - sourceDate: result.tiktokUser.date 124 + sourceDate: result.sourceUser.date 123 125 }; 124 126 }).filter(link => link.sourceAccountId !== undefined); 125 127 ··· 133 135 atprotoDisplayName?: string; 134 136 atprotoAvatar?: string; 135 137 matchScore: number; 138 + postCount: number; 139 + followerCount: number; 136 140 }> = []; 137 141 138 142 const matchedSourceAccountIds: number[] = []; 139 143 140 144 for (const result of results) { 141 - const normalized = result.tiktokUser.username.toLowerCase().replace(/[._-]/g, ''); 145 + const normalized = result.sourceUser.username.toLowerCase().replace(/[._-]/g, ''); 142 146 const sourceAccountId = sourceAccountIdMap.get(normalized); 143 147 144 148 if (sourceAccountId && result.atprotoMatches && result.atprotoMatches.length > 0) { ··· 152 156 atprotoHandle: match.handle, 153 157 atprotoDisplayName: match.displayName, 154 158 atprotoAvatar: match.avatar, 155 - matchScore: match.matchScore 159 + matchScore: match.matchScore, 160 + postCount: match.postCount || 0, 161 + followerCount: match.followerCount || 0, 156 162 }); 157 163 } 158 164 }
+2 -7
src/App.tsx
··· 55 55 (initialResults, platform) => { 56 56 setCurrentPlatform(platform); 57 57 58 - const resultsWithPlatform = initialResults.map(res => ({ 59 - ...res, 60 - sourcePlatform: platform, 61 - })); 62 - 63 - setSearchResults(resultsWithPlatform); 58 + setSearchResults(initialResults); 64 59 setCurrentStep('loading'); 65 60 66 61 const uploadId = crypto.randomUUID(); 67 62 68 63 searchAllUsers( 69 - resultsWithPlatform, 64 + initialResults, 70 65 setStatusMessage, 71 66 () => { 72 67 setCurrentStep('results');
+12 -2
src/components/SearchResultCard.tsx
··· 1 1 import { Video, MessageCircle, Check, UserPlus, ChevronDown } from "lucide-react"; 2 2 import { PLATFORMS } from "../constants/platforms"; 3 - import type { SearchResult, AtprotoMatch, TikTokUser } from '../types'; 3 + import type { SearchResult, AtprotoMatch, SourceUser } from '../types'; 4 4 5 5 6 6 interface SearchResultCardProps { ··· 31 31 <div className="flex items-center justify-between"> 32 32 <div className="flex-1 min-w-0"> 33 33 <div className="font-bold text-gray-900 dark:text-gray-100 truncate"> 34 - @{result.tiktokUser.username} 34 + @{result.sourceUser.username} 35 35 </div> 36 36 <div className="text-xs text-gray-500 dark:text-gray-400"> 37 37 {platform.name} ··· 87 87 </div> 88 88 {match.description && ( 89 89 <div className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{match.description}</div> 90 + )} 91 + {(match.postCount || match.followerCount) && ( 92 + <div className="flex items-center space-x-3 mt-2 text-xs text-gray-500 dark:text-gray-400"> 93 + {match.postCount && match.postCount > 0 && ( 94 + <span>{match.postCount.toLocaleString()} posts</span> 95 + )} 96 + {match.followerCount && match.followerCount > 0 && ( 97 + <span>{match.followerCount.toLocaleString()} followers</span> 98 + )} 99 + </div> 90 100 )} 91 101 <div className="flex items-center space-x-3 mt-2"> 92 102 <span className="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 px-2 py-1 rounded-full font-medium">
+1 -1
src/hooks/useFileUpload.ts
··· 40 40 41 41 // Initialize search results 42 42 const initialResults: SearchResult[] = users.map(user => ({ 43 - tiktokUser: user, // TODO: Rename to sourceUser in types 43 + sourceUser: user, 44 44 atprotoMatches: [], 45 45 isSearching: false, 46 46 selectedMatches: new Set<string>(),
+46 -1
src/hooks/useSearch.ts
··· 3 3 import { SEARCH_CONFIG } from '../constants/platforms'; 4 4 import type { SearchResult, SearchProgress, AtprotoSession } from '../types'; 5 5 6 + function sortSearchResults(results: SearchResult[]): SearchResult[] { 7 + return [...results].sort((a, b) => { 8 + // 1. Users with matches first 9 + const aHasMatches = a.atprotoMatches.length > 0 ? 0 : 1; 10 + const bHasMatches = b.atprotoMatches.length > 0 ? 0 : 1; 11 + if (aHasMatches !== bHasMatches) return aHasMatches - bHasMatches; 12 + 13 + // 2. For matched users, sort by highest posts count of their top match 14 + if (a.atprotoMatches.length > 0 && b.atprotoMatches.length > 0) { 15 + const aTopPosts = a.atprotoMatches[0]?.postCount || 0; 16 + const bTopPosts = b.atprotoMatches[0]?.postCount || 0; 17 + if (aTopPosts !== bTopPosts) return bTopPosts - aTopPosts; 18 + 19 + // 3. Then by followers count 20 + const aTopFollowers = a.atprotoMatches[0]?.followerCount || 0; 21 + const bTopFollowers = b.atprotoMatches[0]?.followerCount || 0; 22 + if (aTopFollowers !== bTopFollowers) return bTopFollowers - aTopFollowers; 23 + } 24 + 25 + // 4. Username as tiebreaker 26 + return a.sourceUser.username.localeCompare(b.sourceUser.username); 27 + }); 28 + } 29 + 6 30 export function useSearch(session: AtprotoSession | null) { 7 31 const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 8 32 const [isSearchingAll, setIsSearchingAll] = useState(false); ··· 38 62 } 39 63 40 64 const batch = resultsToSearch.slice(i, i + BATCH_SIZE); 41 - const usernames = batch.map(r => r.tiktokUser.username); 65 + const usernames = batch.map(r => r.sourceUser.username); 42 66 43 67 // Mark current batch as searching 44 68 setSearchResults(prev => prev.map((result, index) => ··· 72 96 const newSelectedMatches = new Set<string>(); 73 97 74 98 // Auto-select only the first (highest scoring) match 99 + if (batchResult.actors.length > 0) { 100 + newSelectedMatches.add(batchResult.actors[0].did); 101 + } 102 + 103 + return { 104 + ...result, 105 + atprotoMatches: batchResult.actors, 106 + isSearching: false, 107 + error: batchResult.error, 108 + selectedMatches: newSelectedMatches, 109 + }; 110 + } 111 + return result; 112 + })); 113 + 114 + setSearchResults(prev => prev.map((result, index) => { 115 + const batchResultIndex = index - i; 116 + if (batchResultIndex >= 0 && batchResultIndex < data.results.length) { 117 + const batchResult = data.results[batchResultIndex]; 118 + const newSelectedMatches = new Set<string>(); 119 + 75 120 if (batchResult.actors.length > 0) { 76 121 newSelectedMatches.add(batchResult.actors[0].did); 77 122 }
+1 -1
src/lib/apiClient.ts
··· 295 295 const resultsToSave = results 296 296 .filter(r => !r.isSearching) 297 297 .map(r => ({ 298 - tiktokUser: r.tiktokUser, 298 + sourceUser: r.sourceUser, 299 299 atprotoMatches: r.atprotoMatches || [] 300 300 })); 301 301
+34 -13
src/pages/Results.tsx
··· 11 11 description?: string; 12 12 } 13 13 14 - interface TikTokUser { 14 + interface SourceUser { 15 15 username: string; 16 16 date: string; 17 17 } 18 18 19 19 interface SearchResult { 20 - tiktokUser: TikTokUser; 20 + sourceUser: SourceUser; 21 21 atprotoMatches: any[]; 22 22 isSearching: boolean; 23 23 error?: string; ··· 112 112 113 113 {/* Feed Results */} 114 114 <div className="max-w-3xl mx-auto px-4 py-4 space-y-4"> 115 - {searchResults.map((result, idx) => ( 116 - <SearchResultCard 117 - key={idx} 118 - result={result} 119 - resultIndex={idx} 120 - isExpanded={expandedResults.has(idx)} 121 - onToggleExpand={() => onToggleExpand(idx)} 122 - onToggleMatchSelection={(did) => onToggleMatchSelection(idx, did)} 123 - sourcePlatform={sourcePlatform} 124 - /> 125 - ))} 115 + {[...searchResults].sort((a, b) => { 116 + // Sort logic here, match sortSearchResults function 117 + const aHasMatches = a.atprotoMatches.length > 0 ? 0 : 1; 118 + const bHasMatches = b.atprotoMatches.length > 0 ? 0 : 1; 119 + if (aHasMatches !== bHasMatches) return aHasMatches - bHasMatches; 120 + 121 + if (a.atprotoMatches.length > 0 && b.atprotoMatches.length > 0) { 122 + const aTopPosts = a.atprotoMatches[0]?.postCount || 0; 123 + const bTopPosts = b.atprotoMatches[0]?.postCount || 0; 124 + if (aTopPosts !== bTopPosts) return bTopPosts - aTopPosts; 125 + 126 + const aTopFollowers = a.atprotoMatches[0]?.followerCount || 0; 127 + const bTopFollowers = b.atprotoMatches[0]?.followerCount || 0; 128 + if (aTopFollowers !== bTopFollowers) return bTopFollowers - aTopFollowers; 129 + } 130 + 131 + return a.sourceUser.username.localeCompare(b.sourceUser.username); 132 + }).map((result, idx) => { 133 + // Find the original index in unsorted array 134 + const originalIndex = searchResults.findIndex(r => r.sourceUser.username === result.sourceUser.username); 135 + return ( 136 + <SearchResultCard 137 + key={originalIndex} 138 + result={result} 139 + resultIndex={originalIndex} // Use original index for state updates 140 + isExpanded={expandedResults.has(originalIndex)} 141 + onToggleExpand={() => onToggleExpand(originalIndex)} 142 + onToggleMatchSelection={(did) => onToggleMatchSelection(originalIndex, did)} 143 + sourcePlatform={sourcePlatform} 144 + /> 145 + ); 146 + })} 126 147 </div> 127 148 128 149 {/* Fixed Bottom Action Bar */}
+5 -2
src/types/index.ts
··· 8 8 } 9 9 10 10 // TikTok Data Types 11 - export interface TikTokUser { 11 + export interface SourceUser { 12 12 username: string; 13 13 date: string; 14 14 } ··· 22 22 matchScore: number; 23 23 description?: string; 24 24 followed?: boolean; 25 + postCount?: number; 26 + followerCount?: number; 27 + foundAt?: string; 25 28 } 26 29 27 30 export interface SearchResult { 28 - tiktokUser: TikTokUser; 31 + sourceUser: SourceUser; 29 32 atprotoMatches: AtprotoMatch[]; 30 33 isSearching: boolean; 31 34 error?: string;