// 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=

` 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 }); }, });