diff --git a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts index b0e98c9a192..f26baa885a0 100644 --- a/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +++ b/packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts @@ -29,6 +29,12 @@ export default function (server: Server, ctx: AppContext) { let suggestions: Awaited< ReturnType > = [] + let allActors: any[] = [] + + const viewerFollows = db.db + .selectFrom('follow') + .where('creator', '=', viewer) + .select('subjectDid') if (likes.length >= 100) { // get posts to get their authors @@ -72,37 +78,68 @@ export default function (server: Server, ctx: AppContext) { .map(([did]) => authors.find((a) => a.did === did)) .filter(Boolean) as typeof authors - const actors = sortedAuthors - - if (suggestions.length < MAX_RESULTS_LENGTH) { - // backfill with suggested_follow table - const additional = await db.db - .selectFrom('actor') - .innerJoin('suggested_follow', 'actor.did', 'suggested_follow.did') - .where( - 'actor.did', - 'not in', - // exclude any we already have - authorDIDsExcludingActorAndViewer.concat([actorDid, viewer]), - ) - .selectAll() - .execute() - - actors.push(...additional) - } - - // this handles blocks/mutes etc - suggestions = ( - await actorService.views.hydrateProfiles(actors, viewer) - ).filter((account) => { - return ( - !account.viewer?.muted && - !account.viewer?.blocking && - !account.viewer?.blockedBy + allActors = sortedAuthors + } else { + const popularFollows = await db.db + .selectFrom('actor') + .selectAll() + .innerJoin( + db.db + .selectFrom('profile_agg') + .select(['did', 'followersCount']) + .innerJoin( + db.db + .selectFrom('follow') + .selectAll() + .where('creator', '=', actorDid) + .where('subjectDid', '!=', viewer) + .where('subjectDid', 'not in', viewerFollows) + .as('follows'), + 'follows.subjectDid', + 'profile_agg.did', + ) + .orderBy('followersCount', 'desc') + .limit(20) + .as('popularFollows'), + 'actor.did', + 'popularFollows.did', ) - }) + .orderBy('popularFollows.followersCount', 'desc') + .execute() + + allActors = popularFollows } + if (allActors.length < MAX_RESULTS_LENGTH) { + // backfill with suggested_follow table + const additional = await db.db + .selectFrom('actor') + .innerJoin('suggested_follow', 'actor.did', 'suggested_follow.did') + .where( + 'actor.did', + 'not in', + // exclude any we already have + allActors.map((a) => a.did).concat([actorDid, viewer]), + ) + // and aren't already followed by viewer + .where('actor.did', 'not in', viewerFollows) + .selectAll() + .execute() + + allActors.push(...additional) + } + + // this handles blocks/mutes etc + suggestions = ( + await actorService.views.hydrateProfiles(allActors, viewer) + ).filter((account) => { + return ( + !account.viewer?.muted && + !account.viewer?.blocking && + !account.viewer?.blockedBy + ) + }) + return { encoding: 'application/json', body: { diff --git a/packages/bsky/tests/seeds/basic.ts b/packages/bsky/tests/seeds/basic.ts index c1bd7e41e09..efc4c80ef21 100644 --- a/packages/bsky/tests/seeds/basic.ts +++ b/packages/bsky/tests/seeds/basic.ts @@ -23,6 +23,7 @@ export default async (sc: SeedClient, users = true) => { await sc.follow(bob, alice) await sc.follow(bob, carol, createdAtMicroseconds()) await sc.follow(dan, bob, createdAtTimezone()) + await sc.follow(bob, dan) await sc.post(alice, posts.alice[0], undefined, undefined, undefined, { labels: { $type: 'com.atproto.label.defs#selfLabels', diff --git a/packages/bsky/tests/views/suggested-follows.test.ts b/packages/bsky/tests/views/suggested-follows.test.ts index aa8832e1b38..bf3e292f46a 100644 --- a/packages/bsky/tests/views/suggested-follows.test.ts +++ b/packages/bsky/tests/views/suggested-follows.test.ts @@ -128,4 +128,20 @@ describe('suggested follows', () => { sc.getHeaders(sc.dids.bob), ) }) + + it('returns sorted suggested follows based on foafs', async () => { + const result = await agent.api.app.bsky.graph.getSuggestedFollowsByActor( + { + actor: sc.dids.bob, + }, + { headers: await network.serviceHeaders(sc.dids.carol) }, + ) + + expect(result.data.suggestions.length).toBe(3) // backfilled with 2 NPCs + expect( + result.data.suggestions.find((sug) => { + return [sc.dids.alice, sc.dids.carol, sc.dids.bob].includes(sug.did) + }), + ).toBeFalsy() // not actor or viewer or followed + }) })