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;