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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
141 lines · 5.0 KB Raw History
import { defineQuery, type GrainActorProfile, type Photo, type Gallery } from "$hatk";
import { hideLabelsFilter } from "../labels/_hidden.ts";
import { lookupHandles } from "../helpers/lookupHandles.ts";
import { actorSegment } from "../helpers/resolveHandle.ts";

const isProd = process.env.NODE_ENV === "production";
// See hatk.config.ts. Undefined here degrades quietly to localhost URLs in
// production rather than erroring, which is the more dangerous failure.
const prodDomain = process.env.APP_DOMAIN;
const baseUrl = isProd && prodDomain ? `https://${prodDomain}` : "http://127.0.0.1:3000";

export default defineQuery("parts.page.mention.search", async (ctx) => {
  const { params, search, db, blobUrl, ok } = ctx;
  const { search: query = "", scope, limit = 20 } = params;

  // Scoped to a user DID → search their galleries
  if (scope) {
    return searchGalleries(ctx, query, scope, limit);
  }

  // Default → search users
  return searchUsers(ctx, query, limit);
});

async function searchUsers(ctx: any, query: string, limit: number) {
  const { search, blobUrl, ok, db } = ctx;

  if (!query.trim()) return ok({ results: [] });

  const result = await search("social.grain.actor.profile", query, { limit, fuzzy: true });
  const items = await ctx.resolve(result.records.map((r: any) => r.uri));
  const handles = await lookupHandles(
    db,
    items.map((i: any) => i.did),
  );

  const results = items.map((item: any) => ({
    uri: `at://${item.did}/social.grain.actor.profile/self`,
    name: item.value.displayName || item.handle || item.did,
    description: item.value.description || undefined,
    href: `${baseUrl}/profile/${actorSegment(item.did, item.handle ?? handles.get(item.did))}`,
    icon: blobUrl(item.did, item.value.avatar, "avatar") || undefined,
    subscope: {
      scope: item.did,
      label: "Galleries",
    },
  }));

  return ok({ results });
}

async function searchGalleries(ctx: any, query: string, did: string, limit: number) {
  const { db, blobUrl, ok } = ctx;

  // If query is empty, show all galleries by this user
  let galleryRows: {
    uri: string;
    cid: string;
    did: string;
    title: string;
    description: string;
    created_at: string;
  }[];

  if (!query.trim()) {
    galleryRows = (await db.query(
      `SELECT t.uri, t.cid, t.did, t.title, t.description, t.created_at
       FROM "social.grain.gallery" t
       LEFT JOIN _repos r ON t.did = r.did
       WHERE t.did = $1
         AND (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
         AND ${hideLabelsFilter("t.uri")}
         AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
       ORDER BY t.created_at DESC
       LIMIT $2`,
      [did, limit],
    )) as typeof galleryRows;
  } else {
    const result = await ctx.search("social.grain.gallery", query, { limit, fuzzy: true });
    // Filter to only this user's galleries
    galleryRows = result.records
      .filter((r: any) => r.did === did)
      .map((r: any) => ({
        uri: r.uri,
        cid: r.cid,
        did: r.did,
        title: r.value.title,
        description: r.value.description,
        created_at: r.value.createdAt,
      }));
  }

  if (galleryRows.length === 0) return ok({ results: [] });

  // Get first photo for each gallery (for icon/thumbnail)
  const galleryUris = galleryRows.map((r) => r.uri);
  const itemRows = (await db.query(
    `SELECT gi.gallery, gi.item
     FROM "social.grain.gallery.item" gi
     WHERE gi.gallery IN (${galleryUris.map((_: any, i: number) => `$${i + 1}`).join(",")})
     ORDER BY gi.position ASC`,
    galleryUris,
  )) as { gallery: string; item: string }[];

  const firstPhotoUri = new Map<string, string>();
  for (const row of itemRows) {
    if (!firstPhotoUri.has(row.gallery)) firstPhotoUri.set(row.gallery, row.item);
  }

  const photoUris = [...new Set(firstPhotoUri.values())];
  const photos =
    photoUris.length > 0 ? await ctx.getRecords("social.grain.photo", photoUris) : new Map();

  // The link is by handle, like every link the app draws. The embed src stays
  // on the DID: it is pasted into an iframe and never read, and a handle
  // change should not blank an embed on someone else's page.
  const owner = actorSegment(did, (await lookupHandles(db, [did])).get(did));

  const results = galleryRows.map((gallery) => {
    const photoUri = firstPhotoUri.get(gallery.uri);
    const photo = photoUri ? photos.get(photoUri) : null;
    const thumb = photo ? blobUrl(photo.did, photo.value.photo, "feed_thumbnail") : undefined;
    const rkey = gallery.uri.split("/").pop();
    const galleryUrl = `${baseUrl}/profile/${owner}/gallery/${rkey}`;
    const embedUrl = `${baseUrl}/embed/gallery/${gallery.did}/${rkey}`;

    return {
      uri: gallery.uri,
      name: gallery.title,
      description: gallery.description || undefined,
      href: galleryUrl,
      icon: thumb || undefined,
      embed: {
        src: embedUrl,
        aspectRatio: { width: 16, height: 9 },
      },
    };
  });

  return ok({ results });
}