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

Configure Feed

Select the types of activity you want to include in your feed.

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;