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