ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import React, { useMemo } from "react"; 2import { MessageCircle, ChevronDown } from "lucide-react"; 3import type { SearchResult } from "../types"; 4import { getAtprotoAppWithFallback } from "../lib/utils/platform"; 5import type { AtprotoAppId } from "../types/settings"; 6import AvatarWithFallback from "./common/AvatarWithFallback"; 7import FollowButton from "./common/FollowButton"; 8import Badge from "./common/Badge"; 9import { StatBadge } from "./common/Stats"; 10import Card from "./common/Card"; 11import CardItem from "./common/CardItem"; 12 13interface SearchResultCardProps { 14 result: SearchResult; 15 resultIndex: number; 16 isExpanded: boolean; 17 onToggleExpand: () => void; 18 onToggleMatchSelection: (did: string) => void; 19 sourcePlatform: string; 20 destinationAppId?: AtprotoAppId; 21} 22 23// Memoize the match item to prevent unnecessary re-renders 24const MatchItem = React.memo<{ 25 match: any; 26 isSelected: boolean; 27 isFollowed: boolean; 28 currentAppName: string; 29 onToggle: () => void; 30}>(({ match, isSelected, isFollowed, currentAppName, onToggle }) => { 31 return ( 32 <CardItem 33 padding="p-3" 34 badgeIndentClass="sm:pl-[44px]" 35 avatar={ 36 <AvatarWithFallback 37 avatar={match.avatar} 38 handle={match.handle || ""} 39 size="sm" 40 /> 41 } 42 content={ 43 <> 44 {match.displayName && ( 45 <div className="font-semibold text-purple-950 dark:text-cyan-50 leading-tight"> 46 {match.displayName} 47 </div> 48 )} 49 <a 50 href={`https://bsky.app/profile/${match.handle}`} 51 target="_blank" 52 rel="noopener noreferrer" 53 className="text-sm text-purple-750 dark:text-cyan-250 hover:underline leading-tight" 54 > 55 @{match.handle} 56 </a> 57 </> 58 } 59 action={ 60 <FollowButton 61 isFollowed={isFollowed} 62 isSelected={isSelected} 63 onToggle={onToggle} 64 appName={currentAppName} 65 /> 66 } 67 badges={ 68 <> 69 {typeof match.postCount === "number" && match.postCount > 0 && ( 70 <StatBadge value={match.postCount} label="posts" /> 71 )} 72 {typeof match.followerCount === "number" && match.followerCount > 0 && ( 73 <StatBadge value={match.followerCount} label="followers" /> 74 )} 75 <Badge variant="match">{match.matchScore}% match</Badge> 76 </> 77 } 78 description={match.description} 79 /> 80 ); 81}); 82 83MatchItem.displayName = "MatchItem"; 84 85const SearchResultCard = React.memo<SearchResultCardProps>( 86 ({ 87 result, 88 resultIndex, 89 isExpanded, 90 onToggleExpand, 91 onToggleMatchSelection, 92 sourcePlatform, 93 destinationAppId = "bluesky", 94 }) => { 95 const currentApp = useMemo( 96 () => getAtprotoAppWithFallback(destinationAppId), 97 [destinationAppId], 98 ); 99 100 const currentLexicon = useMemo( 101 () => currentApp?.followLexicon || "app.bsky.graph.follow", 102 [currentApp], 103 ); 104 105 const displayMatches = useMemo( 106 () => 107 isExpanded ? result.atprotoMatches : result.atprotoMatches.slice(0, 1), 108 [isExpanded, result.atprotoMatches], 109 ); 110 111 const hasMoreMatches = result.atprotoMatches.length > 1; 112 113 return ( 114 <Card variant="result"> 115 {/* Source User */} 116 <div className="px-4 py-3 bg-purple-100 dark:bg-slate-900 border-b-2 border-cyan-500/30 dark:border-purple-500/30"> 117 <div className="flex justify-between gap-2 items-center"> 118 <div className="flex-1 min-w-0"> 119 <div className="flex flex-wrap gap-x-2 gap-y-1"> 120 <span className="font-bold text-purple-950 dark:text-cyan-50 truncate text-base"> 121 @{result.sourceUser.username} 122 </span> 123 </div> 124 </div> 125 <div className="text-sm text-purple-750 dark:text-cyan-250 whitespace-nowrap flex-shrink-0"> 126 {result.atprotoMatches.length}{" "} 127 {result.atprotoMatches.length === 1 ? "match" : "matches"} 128 </div> 129 </div> 130 </div> 131 132 {/* ATProto Matches */} 133 {result.atprotoMatches.length === 0 ? ( 134 <div className="text-center py-6"> 135 <MessageCircle className="w-8 h-8 mx-auto mb-2 opacity-50 text-purple-750 dark:text-cyan-250" /> 136 <p className="text-sm text-purple-950 dark:text-cyan-50"> 137 Not found on the ATmosphere yet 138 </p> 139 </div> 140 ) : ( 141 <div> 142 {displayMatches.map((match) => { 143 const isFollowedInCurrentApp = 144 match.followStatus?.[currentLexicon] ?? false; 145 const isSelected = result.selectedMatches?.has(match.did); 146 147 return ( 148 <MatchItem 149 key={match.did} 150 match={match} 151 isSelected={isSelected || false} 152 isFollowed={isFollowedInCurrentApp} 153 currentAppName={currentApp?.name || "this app"} 154 onToggle={() => onToggleMatchSelection(match.did)} 155 /> 156 ); 157 })} 158 {hasMoreMatches && ( 159 <button 160 onClick={onToggleExpand} 161 className="w-full py-2 text-sm text-purple-600 hover:text-purple-950 dark:text-cyan-400 dark:hover:text-cyan-50 font-medium transition-colors flex items-center justify-center space-x-1 border-t-2 border-cyan-500/30 dark:border-purple-500/30 hover:border-orange-500 dark:hover:border-amber-400/50" 162 > 163 <span> 164 {isExpanded 165 ? "Show less" 166 : `Show ${result.atprotoMatches.length - 1} more ${result.atprotoMatches.length - 1 === 1 ? "option" : "options"}`} 167 </span> 168 <ChevronDown 169 className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} 170 /> 171 </button> 172 )} 173 </div> 174 )} 175 </Card> 176 ); 177 }, 178); 179 180SearchResultCard.displayName = "SearchResultCard"; 181export default SearchResultCard;