grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: location feed pagination and region name resolution

- getLocations: use majority-vote for region display names instead of
first-wins, fixing "No Saint" showing for Portland
- location feed: filter by H3 parent before paginating so city-level
feeds actually return results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+48 -18
+22 -10
server/feeds/location.ts
··· 26 26 // is a child of the requested city cell. We do this in application code 27 27 // since SQLite doesn't have H3 functions. 28 28 if (isCityLevel) { 29 - // Fetch all galleries with locations, then filter 30 - const { rows, cursor } = await ctx.paginate<{ uri: string; location: string }>( 31 - `SELECT t.uri, t.cid, t.created_at, json_extract(t.location, '$.value') AS location 29 + // Fetch all galleries with locations, filter by H3 parent in JS, then paginate 30 + const limit = ctx.params.limit ? Number(ctx.params.limit) : 30; 31 + const allRows = (await ctx.db.query( 32 + `SELECT t.uri, t.created_at, json_extract(t.location, '$.value') AS location 32 33 FROM "social.grain.gallery" t 33 34 LEFT JOIN _repos r ON t.did = r.did 34 35 WHERE (r.status IS NULL OR r.status != 'takendown') 35 36 AND t.location IS NOT NULL 36 37 AND ${hideLabelsFilter("t.uri")} 37 - AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0`, 38 - { orderBy: "t.created_at" }, 39 - ); 38 + AND (SELECT count(*) FROM "social.grain.gallery.item" gi WHERE gi.gallery = t.uri) > 0 39 + ORDER BY t.created_at DESC`, 40 + )) as { uri: string; created_at: string; location: string }[]; 40 41 41 - const filtered = rows.filter((r) => { 42 + const filtered = allRows.filter((r) => { 42 43 if (!r.location) return false; 43 44 try { 44 45 const cellRes = getResolution(r.location); 45 - // Legacy res-5 cells: exact match 46 46 if (cellRes <= 5) return r.location === location; 47 - // Res-10 cells: check if parent at res 5 matches 48 47 return cellToParent(r.location, 5) === location; 49 48 } catch { 50 49 return false; 51 50 } 52 51 }); 53 52 54 - return ctx.ok({ uris: filtered.map((r) => r.uri), cursor }); 53 + // Manual cursor pagination over filtered results 54 + let startIdx = 0; 55 + if (ctx.params.cursor) { 56 + const cursorUri = atob(ctx.params.cursor); 57 + const idx = filtered.findIndex((r) => r.uri === cursorUri); 58 + if (idx >= 0) startIdx = idx + 1; 59 + } 60 + const page = filtered.slice(startIdx, startIdx + limit); 61 + const cursor = 62 + page.length > 0 && startIdx + limit < filtered.length 63 + ? btoa(page[page.length - 1].uri) 64 + : undefined; 65 + 66 + return ctx.ok({ uris: page.map((r) => r.uri), cursor }); 55 67 } 56 68 57 69 // Venue-level: exact match
+26 -8
server/xrpc/getLocations.ts
··· 26 26 country: string | null; 27 27 }[]; 28 28 29 - // Group by region-level H3 cell 30 - const regionMap = new Map<string, { name: string; h3Index: string; count: number }>(); 29 + // Group by region-level H3 cell, picking the most common locality name 30 + const regionMap = new Map< 31 + string, 32 + { nameCounts: Map<string, number>; h3Index: string; count: number } 33 + >(); 31 34 for (const row of rows) { 32 35 if (!row.h3_index) continue; 33 36 let regionH3: string; ··· 37 40 } catch { 38 41 continue; 39 42 } 43 + const displayName = 44 + [row.locality, row.region, row.country].filter(Boolean).join(", ") || row.name; 40 45 const existing = regionMap.get(regionH3); 41 46 if (existing) { 42 47 existing.count++; 48 + existing.nameCounts.set(displayName, (existing.nameCounts.get(displayName) ?? 0) + 1); 43 49 } else { 44 - const displayName = 45 - [row.locality, row.region, row.country].filter(Boolean).join(", ") || row.name; 46 - regionMap.set(regionH3, { name: displayName, h3Index: regionH3, count: 1 }); 50 + regionMap.set(regionH3, { 51 + nameCounts: new Map([[displayName, 1]]), 52 + h3Index: regionH3, 53 + count: 1, 54 + }); 47 55 } 48 56 } 49 57 50 58 const data = [...regionMap.values()] 51 - .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) 52 - .slice(0, 30) 53 - .map((r) => ({ name: r.name, h3Index: r.h3Index, galleryCount: r.count })); 59 + .map((r) => { 60 + let bestName = ""; 61 + let bestCount = 0; 62 + for (const [name, count] of r.nameCounts) { 63 + if (count > bestCount) { 64 + bestCount = count; 65 + bestName = name; 66 + } 67 + } 68 + return { name: bestName, h3Index: r.h3Index, galleryCount: r.count }; 69 + }) 70 + .sort((a, b) => b.galleryCount - a.galleryCount || a.name.localeCompare(b.name)) 71 + .slice(0, 30); 54 72 55 73 cache = { data, expires: Date.now() + TTL }; 56 74 return data;