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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
248 lines · 10.9 KB Raw History
// Parameterized location feed. Usage:
//   GET /xrpc/dev.hatk.getFeed?feed=location&location=8a28a98a4c67fff&limit=50
//
// Accepts both resolution-10 (venue) and resolution-5 (city) H3 indices.
// For city-level queries, matches galleries whose venue H3 is a child of the city cell.
//
// FOLLOW-UPS (see commit ca49c1b for context):
//
//   1. Replace the `name` display-string param with a `/place/[slug]` URL
//      scheme. The current encoding serialises structured address fields into
//      a display string, then parses them back out below — this works today
//      but will break if a locality contains a comma ("Washington, D.C."),
//      if display names get localised, or if the format evolves. Cleaner
//      alternative: add `locality`/`region`/`country` to LocationItem in the
//      lexicon, carry them through the URL + feed param as structured data,
//      keep `name` purely for display.
//
//   2. grain-native still calls this feed with only `location=<h3>` and no
//      `name` — it falls through to the legacy single-cell path below, so
//      native users don't benefit from the multi-cell union. Fix lives in
//      grain-native's FeedEndpoints.swift + LocationFeedView.swift + pinned
//      feed paths. Additive, non-breaking.

import { defineFeed } from "$hatk";
import { hydrateGalleries } from "../hydrate/galleries.ts";
import { getResolution, cellToParent } from "h3-js";
import { hideLabelsFilter } from "../labels/_hidden.ts";
import { blockMuteFilter } from "../filters/blockMute.ts";
import { expandCountryAliases } from "../helpers/country.ts";
import { galleryFeedTable } from "./_galleryTable.ts";

export default defineFeed({
  collection: "social.grain.gallery",
  label: "Location",

  hydrate: hydrateGalleries,

  async generate(ctx) {
    const name = typeof ctx.params.name === "string" ? ctx.params.name.trim() : "";
    const location = ctx.params.location;

    // Name-based query: unions all H3 cells sharing the same display label.
    // Preferred over H3 when provided, since multiple res-5 cells can carry the
    // same label (e.g. two res-5 cells both labeled "New York, New York, US").
    if (name) {
      // Parse the display name into plausible address interpretations. The
      // sidebar's display format is `[locality, region, country].filter(Boolean).join(", ")`,
      // so the last part is always country (when any address is present) and
      // the position of earlier parts depends on which fields were populated.
      // A 2-part name like "Paris, FR" is locality+country, while "Oregon, US"
      // is region+country — so we try both and union the matches.
      const parts = name
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean);

      type Interp = {
        locality?: string;
        region?: string;
        country?: string;
        // Require the record's region to be NULL or equal this string. Used
        // by the [POI, locality, country] 3-part fallback so that clicking
        // a NYC POI ("India Street, New York, US") matches NYC records
        // (region="New York" = parts[-2]), a Tokyo POI ("Tokyo Midtown,
        // Minato, JP") matches Minato records (region=null), and Seattle
        // clicks ("Seattle, Washington, US") DON'T pull in Washington DC
        // (region="District of Columbia", neither null nor "Washington").
        regionNullOrEqual?: string;
      };
      const interps: Interp[] = [];
      if (parts.length >= 3) {
        // Take the LAST three parts as [locality, region, country] so displays
        // with a POI prefix ("The Space Needle, Seattle, Washington, US" or
        // "Northeast 33rd Drive, Portland, Oregon, US") parse correctly.
        const [locality, region, country] = parts.slice(-3);
        interps.push({ locality, region, country });
        // Also try [POI, locality, country] for POI-inside-locality cases.
        // The regionNullOrEqual guard prevents pulling in same-named
        // localities in unrelated regions.
        interps.push({
          locality: parts[parts.length - 2],
          country: parts[parts.length - 1],
          regionNullOrEqual: parts[parts.length - 2],
        });
      } else if (parts.length === 2) {
        // Ambiguous: could be [locality, country] ("Paris, FR") or
        // [region, country] ("Oregon, US"). Try both; only the right one
        // matches records.
        interps.push({ locality: parts[0], country: parts[1] });
        interps.push({ region: parts[0], country: parts[1] });
      } else if (parts.length === 1) {
        // Ambiguous: could be a country ("Greece") or a locality
        // ("Seattle"). Try both.
        interps.push({ country: parts[0] });
        interps.push({ locality: parts[0] });
      }

      const viewer = ctx.viewer?.did;
      const params: any[] = [];
      let p = 1;

      const interpClauses: string[] = [];
      for (const interp of interps) {
        // Staged locally: an interpretation that turns out to be unsatisfiable
        // must not leave its bind values behind, or every later placeholder
        // number would refer to the wrong parameter.
        const matches: string[] = [];
        const staged: any[] = [];
        let next = p;
        let usable = true;

        if (interp.locality) {
          matches.push(`UPPER(json_extract(t.address, '$.locality')) = UPPER($${next++})`);
          staged.push(interp.locality);
        }
        if (interp.region) {
          matches.push(`UPPER(json_extract(t.address, '$.region')) = UPPER($${next++})`);
          staged.push(interp.region);
        }
        if (interp.country) {
          // Expand "US"/"USA"/etc. all together. A single-word display name is
          // tried as a country first ("Greece"), so this is routinely handed
          // something that is not one ("Waldport") and gets back no aliases.
          // Emitting `IN ()` is a SQLite syntax error that fails the entire
          // query — including the sibling locality interpretation that would
          // have matched — so drop this interpretation instead.
          const aliases = expandCountryAliases(interp.country);
          if (aliases.length === 0) {
            usable = false;
          } else {
            const placeholders = aliases.map(() => `$${next++}`).join(",");
            matches.push(`UPPER(json_extract(t.address, '$.country')) IN (${placeholders})`);
            staged.push(...aliases);
          }
        }
        if (interp.regionNullOrEqual) {
          matches.push(
            `(json_extract(t.address, '$.region') IS NULL OR UPPER(json_extract(t.address, '$.region')) = UPPER($${next++}))`,
          );
          staged.push(interp.regionNullOrEqual);
        }

        if (usable && matches.length) {
          interpClauses.push(`(${matches.join(" AND ")})`);
          params.push(...staged);
          p = next;
        }
      }

      // Also match records whose raw location.name equals the requested name —
      // covers galleries with only a custom location label (no structured address).
      interpClauses.push(`json_extract(t.location, '$.name') = $${p++}`);
      params.push(name);

      const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", `$${p++}`)}` : "";
      if (viewer) params.push(viewer);

      const { rows, cursor } = await ctx.paginate<{ uri: string }>(
        `SELECT t.uri, t.cid, t.sort_at FROM ${galleryFeedTable}
         LEFT JOIN _repos r ON t.did = r.did
         WHERE (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
           AND (${interpClauses.join(" OR ")})
           AND ${hideLabelsFilter("t.uri")}
           AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
           ${bmFilter}`,
        { orderBy: "t.sort_at", params },
      );

      // If the name-based query found matches, or we have no H3 to fall back to,
      // return. Otherwise fall through to the legacy H3 path — at minimum the
      // user sees the clicked gallery's own cell instead of an empty page.
      if (rows.length > 0 || !location) {
        return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
      }
    }

    if (!location) return ctx.ok({ uris: [] });

    const res = getResolution(location);
    const isCityLevel = res <= 5;

    // For city-level queries, we need to check if the gallery's H3 cell
    // is a child of the requested city cell. We do this in application code
    // since SQLite doesn't have H3 functions.
    const viewer = ctx.viewer?.did;

    if (isCityLevel) {
      // Fetch all galleries with locations, filter by H3 parent in JS, then paginate
      const limit = ctx.params.limit ? Number(ctx.params.limit) : 30;
      const bmFilter = viewer ? `AND ${blockMuteFilter("t.did", "$1")}` : "";
      const bmParams = viewer ? [viewer] : [];
      const allRows = (await ctx.db.query(
        `SELECT t.uri, t.sort_at, json_extract(t.location, '$.value') AS location
         FROM ${galleryFeedTable}
         LEFT JOIN _repos r ON t.did = r.did
         WHERE (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
           AND t.location IS NOT NULL
           AND ${hideLabelsFilter("t.uri")}
           AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
           ${bmFilter}
         ORDER BY t.sort_at DESC`,
        bmParams,
      )) as { uri: string; sort_at: string; location: string }[];

      const filtered = allRows.filter((r) => {
        if (!r.location) return false;
        try {
          const cellRes = getResolution(r.location);
          if (cellRes <= 5) return r.location === location;
          return cellToParent(r.location, 5) === location;
        } catch {
          return false;
        }
      });

      // Manual cursor pagination over filtered results
      let startIdx = 0;
      if (ctx.params.cursor) {
        const cursorUri = atob(ctx.params.cursor);
        const idx = filtered.findIndex((r) => r.uri === cursorUri);
        if (idx >= 0) startIdx = idx + 1;
      }
      const page = filtered.slice(startIdx, startIdx + limit);
      const cursor =
        page.length > 0 && startIdx + limit < filtered.length
          ? btoa(page[page.length - 1].uri)
          : undefined;

      return ctx.ok({ uris: page.map((r) => r.uri), cursor });
    }

    // Venue-level: exact match
    const bmFilterVenue = viewer ? `AND ${blockMuteFilter("t.did", "$2")}` : "";
    const bmParamsVenue = viewer ? [viewer] : [];
    const { rows, cursor } = await ctx.paginate<{ uri: string }>(
      `SELECT t.uri, t.cid, t.sort_at FROM ${galleryFeedTable}
       LEFT JOIN _repos r ON t.did = r.did
       WHERE (r.status IS NULL OR r.status NOT IN ('takendown', 'deactivated', 'deleted'))
         AND json_extract(t.location, '$.value') = $1
         AND ${hideLabelsFilter("t.uri")}
         AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0
         ${bmFilterVenue}`,
      { orderBy: "t.sort_at", params: [location, ...bmParamsVenue] },
    );

    return ctx.ok({ uris: rows.map((r) => r.uri), cursor });
  },
});