0
2
import { defineQuery, type GrainActorProfile } from "$hatk";
import { blockMuteFilter } from "../filters/blockMute.ts";
import { lookupHandles } from "../helpers/lookupHandles.ts";
export default defineQuery("social.grain.unspecced.getSuggestedFollows", async (ctx) => {
const { ok, params, lookup, blobUrl, db } = ctx;
const { actor, limit = 10 } = params;
if (!actor) return ok({ items: [] });
// 1. Bsky follows who have grain profiles but viewer doesn't grain-follow
const bskyRows = (await db.query(
`SELECT bf.subject FROM "app.bsky.graph.follow" bf
INNER JOIN "social.grain.actor.profile" gp ON gp.did = bf.subject
LEFT JOIN _repos r ON bf.subject = r.did
WHERE bf.did = $1
AND bf.subject != $1
AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
AND bf.subject NOT IN (
SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1
)
AND ${blockMuteFilter("bf.subject", "$1")}
LIMIT $2`,
[actor, Number(limit)],
)) as { subject: string }[];
const dids = bskyRows.map((r) => r.subject);
// 2. Backfill with popular grain profiles if not enough results
if (dids.length < Number(limit)) {
const remaining = Number(limit) - dids.length;
const exclude = [actor, ...dids];
const placeholders = exclude.map((_, i) => `$${i + 1}`).join(",");
const backfillRows = (await db.query(
`SELECT gp.did,
(SELECT count(*) FROM "social.grain.graph.follow" f WHERE f.subject = gp.did) as follower_count
FROM "social.grain.actor.profile" gp
LEFT JOIN _repos r ON gp.did = r.did
WHERE gp.did NOT IN (${placeholders})
AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
AND gp.did NOT IN (
SELECT gf.subject FROM "social.grain.graph.follow" gf WHERE gf.did = $1
)
AND ${blockMuteFilter("gp.did", "$1")}
ORDER BY follower_count DESC
LIMIT $${exclude.length + 1}`,
[...exclude, remaining],
)) as { did: string }[];
for (const row of backfillRows) {
dids.push(row.did);
}
}
if (dids.length === 0) return ok({ items: [] });
const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids);
// Count followers for each
const followerCounts = new Map<string, number>();
if (dids.length > 0) {
const ph = dids.map((_, i) => `$${i + 1}`).join(",");
const countRows = (await db.query(
`SELECT subject, count(*) as cnt FROM "social.grain.graph.follow"
WHERE subject IN (${ph}) GROUP BY subject`,
dids,
)) as { subject: string; cnt: number }[];
for (const r of countRows) {
followerCounts.set(r.subject, Number(r.cnt));
}
}
const handleMap = await lookupHandles(db, dids);
const items = dids.map((did) => {
const p = profiles.get(did);
return {
did,
handle: p?.handle ?? handleMap.get(did) ?? did,
displayName: p?.value.displayName,
description: p?.value.description,
avatar: p ? blobUrl(did, p.value.avatar, "avatar") : undefined,
followersCount: followerCounts.get(did) ?? 0,
};
});
return ok({ items });
});