+79
netlify/functions/batch-follow-users.ts
+79
netlify/functions/batch-follow-users.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 (
···
57
const { agent, did: userDid } =
58
await SessionManager.getAgentForSession(sessionId);
59
60
// Follow all users
61
const results = [];
62
let consecutiveErrors = 0;
63
const MAX_CONSECUTIVE_ERRORS = 3;
64
65
for (const did of dids) {
66
try {
67
await agent.api.com.atproto.repo.createRecord({
68
repo: userDid,
···
77
results.push({
78
did,
79
success: true,
80
error: null,
81
});
82
83
// Reset error counter on success
84
consecutiveErrors = 0;
85
} catch (error) {
···
88
results.push({
89
did,
90
success: false,
91
error: error instanceof Error ? error.message : "Follow failed",
92
});
93
···
112
113
const successCount = results.filter((r) => r.success).length;
114
const failCount = results.filter((r) => !r.success).length;
115
116
return {
117
statusCode: 200,
···
124
total: dids.length,
125
succeeded: successCount,
126
failed: failCount,
127
results,
128
}),
129
};
···
1
import { Handler, HandlerEvent, HandlerResponse } from "@netlify/functions";
2
import { SessionManager } from "./session-manager";
3
+
import { getDbClient } from "./db";
4
import cookie from "cookie";
5
6
export const handler: Handler = async (
···
58
const { agent, did: userDid } =
59
await SessionManager.getAgentForSession(sessionId);
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
+
96
// Follow all users
97
const results = [];
98
let consecutiveErrors = 0;
99
const MAX_CONSECUTIVE_ERRORS = 3;
100
+
const sql = getDbClient();
101
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
+
127
try {
128
await agent.api.com.atproto.repo.createRecord({
129
repo: userDid,
···
138
results.push({
139
did,
140
success: true,
141
+
alreadyFollowing: false,
142
error: null,
143
});
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
+
157
// Reset error counter on success
158
consecutiveErrors = 0;
159
} catch (error) {
···
162
results.push({
163
did,
164
success: false,
165
+
alreadyFollowing: false,
166
error: error instanceof Error ? error.message : "Follow failed",
167
});
168
···
187
188
const successCount = results.filter((r) => r.success).length;
189
const failCount = results.filter((r) => !r.success).length;
190
+
const alreadyFollowingCount = results.filter(
191
+
(r) => r.alreadyFollowing,
192
+
).length;
193
194
return {
195
statusCode: 200,
···
202
total: dids.length,
203
succeeded: successCount,
204
failed: failCount,
205
+
alreadyFollowing: alreadyFollowingCount,
206
results,
207
}),
208
};
+51
netlify/functions/batch-search-actors.ts
+51
netlify/functions/batch-search-actors.ts
···
148
});
149
}
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
+
202
return {
203
statusCode: 200,
204
headers: {
+135
netlify/functions/check-follow-status.ts
+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
+11
-5
netlify/functions/db-helpers.ts
···
56
matchScore: number,
57
postCount: number,
58
followerCount: number,
59
): Promise<number> {
60
const sql = getDbClient();
61
const result = await sql`
62
INSERT INTO atproto_matches (
63
source_account_id, atproto_did, atproto_handle,
64
atproto_display_name, atproto_avatar, match_score,
65
-
post_count, follower_count
66
)
67
VALUES (
68
${sourceAccountId}, ${atprotoDid}, ${atprotoHandle},
69
${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore},
70
-
${postCount || 0}, ${followerCount || 0}
71
)
72
ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET
73
atproto_handle = ${atprotoHandle},
···
76
match_score = ${matchScore},
77
post_count = ${postCount},
78
follower_count = ${followerCount},
79
last_verified = NOW()
80
RETURNING id
81
`;
···
192
matchScore: number;
193
postCount?: number;
194
followerCount?: number;
195
}>,
196
): Promise<Map<string, number>> {
197
const sql = getDbClient();
···
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);
210
211
const result = await sql`
212
INSERT INTO atproto_matches (
213
source_account_id, atproto_did, atproto_handle,
214
atproto_display_name, atproto_avatar, atproto_description,
215
-
match_score, post_count, follower_count
216
)
217
SELECT * FROM UNNEST(
218
${sourceAccountId}::integer[],
···
223
${atprotoDescription}::text[],
224
${matchScore}::integer[],
225
${postCount}::integer[],
226
-
${followerCount}::integer[]
227
) AS t(
228
source_account_id, atproto_did, atproto_handle,
229
atproto_display_name, atproto_avatar, match_score,
230
-
post_count, follower_count
231
)
232
ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET
233
atproto_handle = EXCLUDED.atproto_handle,
···
237
match_score = EXCLUDED.match_score,
238
post_count = EXCLUDED.post_count,
239
follower_count = EXCLUDED.follower_count,
240
last_verified = NOW()
241
RETURNING id, source_account_id, atproto_did
242
`;
···
56
matchScore: number,
57
postCount: number,
58
followerCount: number,
59
+
followStatus?: Record<string, boolean>,
60
): Promise<number> {
61
const sql = getDbClient();
62
const result = await sql`
63
INSERT INTO atproto_matches (
64
source_account_id, atproto_did, atproto_handle,
65
atproto_display_name, atproto_avatar, match_score,
66
+
post_count, follower_count, follow_status
67
)
68
VALUES (
69
${sourceAccountId}, ${atprotoDid}, ${atprotoHandle},
70
${atprotoDisplayName || null}, ${atprotoAvatar || null}, ${matchScore},
71
+
${postCount || 0}, ${followerCount || 0}, ${JSON.stringify(followStatus || {})}
72
)
73
ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET
74
atproto_handle = ${atprotoHandle},
···
77
match_score = ${matchScore},
78
post_count = ${postCount},
79
follower_count = ${followerCount},
80
+
follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || ${JSON.stringify(followStatus || {})},
81
last_verified = NOW()
82
RETURNING id
83
`;
···
194
matchScore: number;
195
postCount?: number;
196
followerCount?: number;
197
+
followStatus?: Record<string, boolean>;
198
}>,
199
): Promise<Map<string, number>> {
200
const sql = getDbClient();
···
210
const matchScore = matches.map((m) => m.matchScore);
211
const postCount = matches.map((m) => m.postCount || 0);
212
const followerCount = matches.map((m) => m.followerCount || 0);
213
+
const followStatus = matches.map((m) => JSON.stringify(m.followStatus || {}));
214
215
const result = await sql`
216
INSERT INTO atproto_matches (
217
source_account_id, atproto_did, atproto_handle,
218
atproto_display_name, atproto_avatar, atproto_description,
219
+
match_score, post_count, follower_count, follow_status
220
)
221
SELECT * FROM UNNEST(
222
${sourceAccountId}::integer[],
···
227
${atprotoDescription}::text[],
228
${matchScore}::integer[],
229
${postCount}::integer[],
230
+
${followerCount}::integer[],
231
+
${followStatus}::jsonb[]
232
) AS t(
233
source_account_id, atproto_did, atproto_handle,
234
atproto_display_name, atproto_avatar, match_score,
235
+
post_count, follower_count, follow_status
236
)
237
ON CONFLICT (source_account_id, atproto_did) DO UPDATE SET
238
atproto_handle = EXCLUDED.atproto_handle,
···
242
match_score = EXCLUDED.match_score,
243
post_count = EXCLUDED.post_count,
244
follower_count = EXCLUDED.follower_count,
245
+
follow_status = COALESCE(atproto_matches.follow_status, '{}'::jsonb) || EXCLUDED.follow_status,
246
last_verified = NOW()
247
RETURNING id, source_account_id, atproto_did
248
`;
+7
-3
netlify/functions/db.ts
+7
-3
netlify/functions/db.ts
···
108
found_at TIMESTAMP DEFAULT NOW(),
109
last_verified TIMESTAMP,
110
is_active BOOLEAN DEFAULT TRUE,
111
UNIQUE(source_account_id, atproto_did)
112
)
113
`;
···
143
)
144
`;
145
146
-
// ==================== ENHANCED INDEXES FOR PHASE 2 ====================
147
-
148
// Existing indexes
149
await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`;
150
await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`;
···
156
await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`;
157
await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`;
158
159
-
// NEW: Enhanced indexes for common query patterns
160
161
// For sorting
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
184
// For bulk operations - normalized username lookups
185
await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_normalized ON source_accounts(normalized_username, source_platform)`;
186
187
console.log("✅ Database indexes created/verified");
188
}
···
108
found_at TIMESTAMP DEFAULT NOW(),
109
last_verified TIMESTAMP,
110
is_active BOOLEAN DEFAULT TRUE,
111
+
follow_status JSONB DEFAULT '{}',
112
+
last_follow_check TIMESTAMP,
113
UNIQUE(source_account_id, atproto_did)
114
)
115
`;
···
145
)
146
`;
147
148
// Existing indexes
149
await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_to_check ON source_accounts(source_platform, match_found, last_checked)`;
150
await sql`CREATE INDEX IF NOT EXISTS idx_source_accounts_platform ON source_accounts(source_platform)`;
···
156
await sql`CREATE INDEX IF NOT EXISTS idx_user_match_status_did_followed ON user_match_status(did, followed)`;
157
await sql`CREATE INDEX IF NOT EXISTS idx_notification_queue_pending ON notification_queue(sent, created_at) WHERE sent = false`;
158
159
+
// ======== Enhanced indexes for common query patterns =========
160
161
// For sorting
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
184
// For bulk operations - normalized username lookups
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)`;
190
191
console.log("✅ Database indexes created/verified");
192
}
+17
-14
netlify/functions/get-upload-details.ts
+17
-14
netlify/functions/get-upload-details.ts
···
82
// Fetch paginated results with optimized query
83
const results = await sql`
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,
99
-- Calculate if this is a new match (found after upload creation)
100
CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match
101
FROM user_source_follows usf
···
153
foundAt: row.found_at,
154
followed: row.followed || false,
155
dismissed: row.dismissed || false,
156
});
157
}
158
});
···
82
// Fetch paginated results with optimized query
83
const results = await sql`
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
+
am.follow_status,
98
+
am.last_follow_check,
99
+
ums.followed,
100
+
ums.dismissed,
101
-- Calculate if this is a new match (found after upload creation)
102
CASE WHEN am.found_at > uu.created_at THEN 1 ELSE 0 END as is_new_match
103
FROM user_source_follows usf
···
155
foundAt: row.found_at,
156
followed: row.followed || false,
157
dismissed: row.dismissed || false,
158
+
followStatus: row.follow_status || {},
159
});
160
}
161
});
+4
src/App.tsx
+4
src/App.tsx
···
11
import { useFileUpload } from "./hooks/useFileUpload";
12
import { useTheme } from "./hooks/useTheme";
13
import Firefly from "./components/Firefly";
14
import { DEFAULT_SETTINGS } from "./types/settings";
15
import type { UserSettings } from "./types/settings";
16
···
86
setCurrentStep("loading");
87
88
const uploadId = crypto.randomUUID();
89
90
searchAllUsers(initialResults, setStatusMessage, () => {
91
setCurrentStep("results");
···
302
isFollowing={isFollowing}
303
currentStep={currentStep}
304
sourcePlatform={currentPlatform}
305
reducedMotion={reducedMotion}
306
isDark={isDark}
307
onToggleTheme={toggleTheme}
···
11
import { useFileUpload } from "./hooks/useFileUpload";
12
import { useTheme } from "./hooks/useTheme";
13
import Firefly from "./components/Firefly";
14
+
import { ATPROTO_APPS } from "./constants/atprotoApps";
15
import { DEFAULT_SETTINGS } from "./types/settings";
16
import type { UserSettings } from "./types/settings";
17
···
87
setCurrentStep("loading");
88
89
const uploadId = crypto.randomUUID();
90
+
const followLexicon =
91
+
ATPROTO_APPS[currentDestinationAppId]?.followLexicon;
92
93
searchAllUsers(initialResults, setStatusMessage, () => {
94
setCurrentStep("results");
···
305
isFollowing={isFollowing}
306
currentStep={currentStep}
307
sourcePlatform={currentPlatform}
308
+
destinationAppId={currentDestinationAppId}
309
reducedMotion={reducedMotion}
310
isDark={isDark}
311
onToggleTheme={toggleTheme}
+18
-7
src/components/SearchResultCard.tsx
+18
-7
src/components/SearchResultCard.tsx
···
7
UserCheck,
8
} from "lucide-react";
9
import { PLATFORMS } from "../constants/platforms";
10
import type { SearchResult } from "../types";
11
12
interface SearchResultCardProps {
13
result: SearchResult;
···
16
onToggleExpand: () => void;
17
onToggleMatchSelection: (did: string) => void;
18
sourcePlatform: string;
19
}
20
21
export default function SearchResultCard({
···
25
onToggleExpand,
26
onToggleMatchSelection,
27
sourcePlatform,
28
}: SearchResultCardProps) {
29
const displayMatches = isExpanded
30
? result.atprotoMatches
···
32
const hasMoreMatches = result.atprotoMatches.length > 1;
33
const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok;
34
35
return (
36
<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
{/* Source User */}
···
64
) : (
65
<div className="">
66
{displayMatches.map((match) => {
67
-
const isFollowed = match.followed;
68
const isSelected = result.selectedMatches?.has(match.did);
69
return (
70
<div
71
key={match.did}
···
133
{/* Select/Follow Button */}
134
<button
135
onClick={() => onToggleMatchSelection(match.did)}
136
-
disabled={isFollowed}
137
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"
140
: isSelected
141
? "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
: "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
}`}
144
title={
145
-
isFollowed
146
-
? "Already followed"
147
: isSelected
148
? "Selected to follow"
149
: "Select to follow"
150
}
151
>
152
-
{isFollowed ? (
153
<Check className="w-4 h-4" />
154
) : isSelected ? (
155
<UserCheck className="w-4 h-4" />
···
7
UserCheck,
8
} from "lucide-react";
9
import { PLATFORMS } from "../constants/platforms";
10
+
import { ATPROTO_APPS } from "../constants/atprotoApps";
11
import type { SearchResult } from "../types";
12
+
import type { AtprotoAppId } from "../types/settings";
13
14
interface SearchResultCardProps {
15
result: SearchResult;
···
18
onToggleExpand: () => void;
19
onToggleMatchSelection: (did: string) => void;
20
sourcePlatform: string;
21
+
destinationAppId?: AtprotoAppId;
22
}
23
24
export default function SearchResultCard({
···
28
onToggleExpand,
29
onToggleMatchSelection,
30
sourcePlatform,
31
+
destinationAppId = "bluesky",
32
}: SearchResultCardProps) {
33
const displayMatches = isExpanded
34
? result.atprotoMatches
···
36
const hasMoreMatches = result.atprotoMatches.length > 1;
37
const platform = PLATFORMS[sourcePlatform] || PLATFORMS.tiktok;
38
39
+
// Get current follow lexicon
40
+
const currentApp = ATPROTO_APPS[destinationAppId];
41
+
const currentLexicon = currentApp?.followLexicon || "app.bsky.graph.follow";
42
+
43
return (
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">
45
{/* Source User */}
···
72
) : (
73
<div className="">
74
{displayMatches.map((match) => {
75
+
// Check follow status for current lexicon
76
+
const isFollowedInCurrentApp =
77
+
match.followStatus?.[currentLexicon] ?? match.followed ?? false;
78
const isSelected = result.selectedMatches?.has(match.did);
79
+
80
return (
81
<div
82
key={match.did}
···
144
{/* Select/Follow Button */}
145
<button
146
onClick={() => onToggleMatchSelection(match.did)}
147
+
disabled={isFollowedInCurrentApp}
148
className={`p-2 rounded-full font-medium transition-all flex-shrink-0 self-start ${
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"
151
: isSelected
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"
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"
154
}`}
155
title={
156
+
isFollowedInCurrentApp
157
+
? `Already following on ${currentApp?.name || "this app"}`
158
: isSelected
159
? "Selected to follow"
160
: "Select to follow"
161
}
162
>
163
+
{isFollowedInCurrentApp ? (
164
<Check className="w-4 h-4" />
165
) : isSelected ? (
166
<UserCheck className="w-4 h-4" />
+72
-7
src/hooks/useFollows.ts
+72
-7
src/hooks/useFollows.ts
···
13
destinationAppId: AtprotoAppId,
14
) {
15
const [isFollowing, setIsFollowing] = useState(false);
16
17
async function followSelectedUsers(
18
onUpdate: (message: string) => void,
···
31
return;
32
}
33
34
-
// Follow users
35
const selectedUsers = searchResults.flatMap((result, resultIndex) =>
36
result.atprotoMatches
37
.filter((match) => result.selectedMatches?.has(match.did))
···
45
return;
46
}
47
48
setIsFollowing(true);
49
onUpdate(
50
-
`Following ${selectedUsers.length} users on ${destinationName}...`,
51
);
52
let totalFollowed = 0;
53
let totalFailed = 0;
···
55
try {
56
const { BATCH_SIZE } = FOLLOW_CONFIG;
57
58
-
for (let i = 0; i < selectedUsers.length; i += BATCH_SIZE) {
59
-
const batch = selectedUsers.slice(i, i + BATCH_SIZE);
60
const dids = batch.map((user) => user.did);
61
62
try {
···
77
atprotoMatches: searchResult.atprotoMatches.map(
78
(match) =>
79
match.did === result.did
80
-
? { ...match, followed: true }
81
: match,
82
),
83
}
···
89
});
90
91
onUpdate(
92
-
`Followed ${totalFollowed} of ${selectedUsers.length} users`,
93
);
94
} catch (error) {
95
totalFailed += batch.length;
···
99
// Rate limit handling is in the backend
100
}
101
102
-
const finalMsg = `Successfully followed ${totalFollowed} users${totalFailed > 0 ? `. ${totalFailed} failed.` : ""}`;
103
onUpdate(finalMsg);
104
} catch (error) {
105
console.error("Batch follow error:", error);
···
111
112
return {
113
isFollowing,
114
followSelectedUsers,
115
};
116
}
···
13
destinationAppId: AtprotoAppId,
14
) {
15
const [isFollowing, setIsFollowing] = useState(false);
16
+
const [isCheckingFollowStatus, setIsCheckingFollowStatus] = useState(false);
17
18
async function followSelectedUsers(
19
onUpdate: (message: string) => void,
···
32
return;
33
}
34
35
+
// Get selected users
36
const selectedUsers = searchResults.flatMap((result, resultIndex) =>
37
result.atprotoMatches
38
.filter((match) => result.selectedMatches?.has(match.did))
···
46
return;
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
+
100
setIsFollowing(true);
101
onUpdate(
102
+
`Following ${usersToFollow.length} users on ${destinationName}...`,
103
);
104
let totalFollowed = 0;
105
let totalFailed = 0;
···
107
try {
108
const { BATCH_SIZE } = FOLLOW_CONFIG;
109
110
+
for (let i = 0; i < usersToFollow.length; i += BATCH_SIZE) {
111
+
const batch = usersToFollow.slice(i, i + BATCH_SIZE);
112
const dids = batch.map((user) => user.did);
113
114
try {
···
129
atprotoMatches: searchResult.atprotoMatches.map(
130
(match) =>
131
match.did === result.did
132
+
? {
133
+
...match,
134
+
followed: true, // Backward compatibility
135
+
followStatus: {
136
+
...match.followStatus,
137
+
[followLexicon]: true,
138
+
},
139
+
}
140
: match,
141
),
142
}
···
148
});
149
150
onUpdate(
151
+
`Followed ${totalFollowed} of ${usersToFollow.length} users`,
152
);
153
} catch (error) {
154
totalFailed += batch.length;
···
158
// Rate limit handling is in the backend
159
}
160
161
+
const finalMsg =
162
+
`Successfully followed ${totalFollowed} users` +
163
+
(alreadyFollowingCount > 0
164
+
? ` (${alreadyFollowingCount} already followed)`
165
+
: "") +
166
+
(totalFailed > 0 ? `. ${totalFailed} failed.` : "");
167
onUpdate(finalMsg);
168
} catch (error) {
169
console.error("Batch follow error:", error);
···
175
176
return {
177
isFollowing,
178
+
isCheckingFollowStatus,
179
followSelectedUsers,
180
};
181
}
+5
-1
src/hooks/useSearch.ts
+5
-1
src/hooks/useSearch.ts
···
43
resultsToSearch: SearchResult[],
44
onProgressUpdate: (message: string) => void,
45
onComplete: () => void,
46
) {
47
if (!session || resultsToSearch.length === 0) return;
48
···
80
);
81
82
try {
83
-
const data = await apiClient.batchSearchActors(usernames);
84
85
// Reset error counter on success
86
consecutiveErrors = 0;
···
43
resultsToSearch: SearchResult[],
44
onProgressUpdate: (message: string) => void,
45
onComplete: () => void,
46
+
followLexicon?: string,
47
) {
48
if (!session || resultsToSearch.length === 0) return;
49
···
81
);
82
83
try {
84
+
const data = await apiClient.batchSearchActors(
85
+
usernames,
86
+
followLexicon,
87
+
);
88
89
// Reset error counter on success
90
consecutiveErrors = 0;
+20
src/lib/apiClient/mockApiClient.ts
+20
src/lib/apiClient/mockApiClient.ts
···
29
description: `Mock profile for ${username}`,
30
postCount: Math.floor(Math.random() * 1000),
31
followerCount: Math.floor(Math.random() * 5000),
32
}));
33
}
34
···
70
localStorage.removeItem("mock_uploads");
71
},
72
73
async getUploads(): Promise<{ uploads: any[] }> {
74
await delay(300);
75
console.log("[MOCK] Getting uploads");
···
110
111
async batchSearchActors(
112
usernames: string[],
113
): Promise<{ results: BatchSearchResult[] }> {
114
await delay(800); // Simulate API delay
115
console.log("[MOCK] Searching for:", usernames);
···
29
description: `Mock profile for ${username}`,
30
postCount: Math.floor(Math.random() * 1000),
31
followerCount: Math.floor(Math.random() * 5000),
32
+
followStatus: {
33
+
"app.bsky.graph.follow": Math.random() < 0.3, // 30% already following
34
+
},
35
}));
36
}
37
···
73
localStorage.removeItem("mock_uploads");
74
},
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
+
92
async getUploads(): Promise<{ uploads: any[] }> {
93
await delay(300);
94
console.log("[MOCK] Getting uploads");
···
129
130
async batchSearchActors(
131
usernames: string[],
132
+
followLexicon?: string,
133
): Promise<{ results: BatchSearchResult[] }> {
134
await delay(800); // Simulate API delay
135
console.log("[MOCK] Searching for:", usernames);
+38
-3
src/lib/apiClient/realApiClient.ts
+38
-3
src/lib/apiClient/realApiClient.ts
···
247
return { results: allResults };
248
},
249
250
// Search Operations
251
async batchSearchActors(
252
usernames: string[],
253
): Promise<{ results: BatchSearchResult[] }> {
254
// Create cache key from sorted usernames (so order doesn't matter)
255
-
const cacheKey = `search-${usernames.slice().sort().join(",")}`;
256
const cached = cache.get<any>(cacheKey, 10 * 60 * 1000);
257
if (cached) {
258
console.log(
···
267
method: "POST",
268
credentials: "include",
269
headers: { "Content-Type": "application/json" },
270
-
body: JSON.stringify({ usernames }),
271
});
272
273
if (!res.ok) {
···
291
total: number;
292
succeeded: number;
293
failed: number;
294
results: BatchFollowResult[];
295
}> {
296
const res = await fetch("/.netlify/functions/batch-follow-users", {
···
306
307
const data = await res.json();
308
309
-
// Invalidate uploads cache after following
310
cache.invalidate("uploads");
311
cache.invalidatePattern("upload-details");
312
313
return data;
314
},
···
247
return { results: allResults };
248
},
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
+
282
// Search Operations
283
async batchSearchActors(
284
usernames: string[],
285
+
followLexicon?: string,
286
): Promise<{ results: BatchSearchResult[] }> {
287
// Create cache key from sorted usernames (so order doesn't matter)
288
+
const cacheKey = `search-${followLexicon || "default"}-${usernames.slice().sort().join(",")}`;
289
const cached = cache.get<any>(cacheKey, 10 * 60 * 1000);
290
if (cached) {
291
console.log(
···
300
method: "POST",
301
credentials: "include",
302
headers: { "Content-Type": "application/json" },
303
+
body: JSON.stringify({ usernames, followLexicon }),
304
});
305
306
if (!res.ok) {
···
324
total: number;
325
succeeded: number;
326
failed: number;
327
+
alreadyFollowing: number;
328
results: BatchFollowResult[];
329
}> {
330
const res = await fetch("/.netlify/functions/batch-follow-users", {
···
340
341
const data = await res.json();
342
343
+
// Invalidate caches after following
344
cache.invalidate("uploads");
345
cache.invalidatePattern("upload-details");
346
+
cache.invalidatePattern("follow-status");
347
348
return data;
349
},
+4
src/pages/Results.tsx
+4
src/pages/Results.tsx
···
2
import { PLATFORMS } from "../constants/platforms";
3
import AppHeader from "../components/AppHeader";
4
import SearchResultCard from "../components/SearchResultCard";
5
6
interface atprotoSession {
7
did: string;
···
41
isFollowing: boolean;
42
currentStep: string;
43
sourcePlatform: string;
44
reducedMotion?: boolean;
45
isDark?: boolean;
46
onToggleTheme?: () => void;
···
63
isFollowing,
64
currentStep,
65
sourcePlatform,
66
reducedMotion = false,
67
isDark = false,
68
onToggleTheme,
···
185
onToggleMatchSelection(originalIndex, did)
186
}
187
sourcePlatform={sourcePlatform}
188
/>
189
);
190
})}
···
2
import { PLATFORMS } from "../constants/platforms";
3
import AppHeader from "../components/AppHeader";
4
import SearchResultCard from "../components/SearchResultCard";
5
+
import type { AtprotoAppId } from "../types/settings";
6
7
interface atprotoSession {
8
did: string;
···
42
isFollowing: boolean;
43
currentStep: string;
44
sourcePlatform: string;
45
+
destinationAppId: AtprotoAppId;
46
reducedMotion?: boolean;
47
isDark?: boolean;
48
onToggleTheme?: () => void;
···
65
isFollowing,
66
currentStep,
67
sourcePlatform,
68
+
destinationAppId,
69
reducedMotion = false,
70
isDark = false,
71
onToggleTheme,
···
188
onToggleMatchSelection(originalIndex, did)
189
}
190
sourcePlatform={sourcePlatform}
191
+
destinationAppId={destinationAppId}
192
/>
193
);
194
})}
+3
-1
src/types/index.ts
+3
-1
src/types/index.ts
···
21
avatar?: string;
22
matchScore: number;
23
description?: string;
24
+
followed?: boolean; // DEPRECATED - kept for backward compatibility
25
+
followStatus?: Record<string, boolean>;
26
postCount?: number;
27
followerCount?: number;
28
foundAt?: string;
···
63
export interface BatchFollowResult {
64
did: string;
65
success: boolean;
66
+
alreadyFollowing?: boolean;
67
error: string | null;
68
}
69