~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
59 lines · 2.4 KB Raw History
// Returns followers of `actor` that `viewer` also follows.
//   GET /xrpc/social.grain.unspecced.getKnownFollowers?actor=did:...&viewer=did:...

import { defineQuery, type GrainActorProfile } from "$hatk";
import { lookupHandles } from "../helpers/lookupHandles.ts";
import { blockFilter } from "../filters/blockMute.ts";
import { resolveHandle } from "../helpers/resolveHandle.ts";

export default defineQuery("social.grain.unspecced.getKnownFollowers", async (ctx) => {
  const { ok, params, lookup, blobUrl } = ctx;
  const { viewer, limit = 50 } = params;

  const actor = params.actor ? await resolveHandle(ctx.db, params.actor) : null;
  if (!actor || !viewer || actor === viewer) return ok({ items: [] });

  // Find DIDs that follow `actor` AND are followed by `viewer`.
  //
  // Taken-down accounts are hidden from everyone, matching every feed. Blocked
  // accounts (either direction) are hidden too — this list is rendered as a
  // facepile on the profile, and a block should not leave a face behind.
  // Mutes are not applied, matching getGalleryFavorites: a mute hides someone's
  // content, not the fact that they follow someone.
  //
  // GROUP BY collapses the fan-out from the f2 join, which duplicates a row
  // whenever the viewer holds more than one follow record for the same account
  // (this happens in prod). Deduping after LIMIT would silently shrink the page.
  const rows = (await ctx.db.query(
    `SELECT f1.did AS did
     FROM "social.grain.graph.follow" f1
     JOIN "social.grain.graph.follow" f2 ON f2.did = $2 AND f2.subject = f1.did
     LEFT JOIN _repos r ON r.did = f1.did
     WHERE f1.subject = $1
       AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
       AND ${blockFilter("f1.did", "$2")}
     GROUP BY f1.did
     ORDER BY MAX(f1.created_at) DESC
     LIMIT $3`,
    [actor, viewer, Number(limit)],
  )) as { did: string }[];

  const dids = [...new Set(rows.map((r) => r.did))];

  const profiles = await lookup<GrainActorProfile>("social.grain.actor.profile", "did", dids);

  const handleMap = await lookupHandles(ctx.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,
    };
  });

  return ok({ items });
});