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: consistent country display across sidebar and gallery cards

- getLocations: use normalised ISO-2 code in multi-part display names so
all rows in a group render identically (fixes "Portland, Oregon, USA"
appearing alongside "...US" entries). Country-only groups expand to the
full country name via Intl.DisplayNames ("GR" → "Greece").
- country helper: add name→code reverse lookup so clicking "Greece" in the
sidebar still matches records stored as "GR". Brute-forces 2-letter codes
since Intl.supportedValuesOf doesn't accept "region". Prefers earlier
alphabetical codes when names collide (GB wins over UK).
- formatStoredLocation: normalise the country tail on gallery cards and
suppress it when the primary label already names the country (fixes
"Greece, GR" on the gallery card).

+90 -9
+45 -4
server/helpers/country.ts
··· 4 4 // address.country, so we normalize at query/aggregation time. Variants are 5 5 // upper-cased on match, so spelling case doesn't matter. 6 6 7 - // Only the variants actually observed in the data. Add more as they appear. 8 - // 7 + // Known spelling variants not covered by Intl (e.g. "USA" → "US"). 9 8 // The single observed non-canonical value ("USA") comes from one external 10 9 // DID (timtrautmann.com) posting to the grain lexicon via a third-party tool 11 10 // — grain-web and grain-native both uppercase `country_code` from Nominatim ··· 18 17 USA: "US", 19 18 }; 20 19 20 + // Build a name → ISO-2 reverse map from Intl so "Greece" resolves to "GR", 21 + // "United States" to "US", etc. Keys are upper-cased for case-insensitive match. 22 + const regionNames = (() => { 23 + try { 24 + return new Intl.DisplayNames(["en"], { type: "region" }); 25 + } catch { 26 + return null; 27 + } 28 + })(); 29 + 30 + // Brute-force enumerate 2-letter ISO codes and build a reverse map. 31 + // Iterates A–Z so when two codes share a display name (e.g. "United Kingdom" 32 + // → GB and UK), the earlier alphabetical code wins. For GB/UK this lands on 33 + // GB, which is what grain-web/grain-native store (Nominatim returns "gb"). 34 + const NAME_TO_CODE: Record<string, string> = (() => { 35 + const map: Record<string, string> = {}; 36 + if (!regionNames) return map; 37 + for (let i = 65; i <= 90; i++) { 38 + for (let j = 65; j <= 90; j++) { 39 + const code = String.fromCharCode(i, j); 40 + let name: string | undefined; 41 + try { 42 + name = regionNames.of(code); 43 + } catch { 44 + continue; 45 + } 46 + if (!name || name === code) continue; 47 + const key = name.toUpperCase(); 48 + if (!map[key]) map[key] = code; 49 + } 50 + } 51 + return map; 52 + })(); 53 + 21 54 /** Return the canonical ISO-2 code for a country string, or null if unrecognized/empty. */ 22 55 export function normalizeCountry(raw: string | null | undefined): string | null { 23 56 if (!raw) return null; 24 57 const s = raw.trim().toUpperCase(); 25 58 if (!s) return null; 26 - return COUNTRY_ALIASES[s] ?? s; 59 + if (COUNTRY_ALIASES[s]) return COUNTRY_ALIASES[s]; 60 + if (NAME_TO_CODE[s]) return NAME_TO_CODE[s]; 61 + return s; 27 62 } 28 63 29 - /** Return all raw country strings that normalize to the same canonical code, upper-cased. */ 64 + /** 65 + * Return all raw country strings that normalize to the same canonical code, 66 + * upper-cased. Includes ISO-2, known aliases, and the full English name so 67 + * clicking a sidebar entry like "Greece" still matches records stored as "GR". 68 + */ 30 69 export function expandCountryAliases(raw: string): string[] { 31 70 const canon = normalizeCountry(raw); 32 71 if (!canon) return []; ··· 34 73 for (const [alias, c] of Object.entries(COUNTRY_ALIASES)) { 35 74 if (c === canon) set.add(alias); 36 75 } 76 + const fullName = regionNames?.of(canon); 77 + if (fullName) set.add(fullName.toUpperCase()); 37 78 return [...set]; 38 79 }
+16 -1
server/helpers/formatLocation.ts
··· 1 + import { normalizeCountry } from "./country.ts"; 2 + 1 3 /** 2 4 * Build a display label for a stored location + address pair. 3 5 * ··· 10 12 * then append locality/region/country while dropping case-insensitive adjacent 11 13 * duplicates. This preserves POI context ("Blue Bottle Coffee, Oakland, 12 14 * California, US") and collapses redundancy in city fallbacks ("New York, US"). 15 + * 16 + * The country tail is normalized to its ISO-2 form (so "USA" becomes "US", 17 + * matching the sidebar) and is suppressed when the primary label already 18 + * represents that country (avoiding "Greece, GR"). 13 19 * 14 20 * Legacy records (community.lexicon.location.hthree) without structured 15 21 * address use the stored name as-is so we don't strip useful commas. ··· 36 42 appendIfDistinct(primaryLabel); 37 43 appendIfDistinct(address?.locality); 38 44 appendIfDistinct(address?.region); 39 - appendIfDistinct(address?.country); 45 + 46 + // Suppress the country tail when the primary label already names the same 47 + // country — e.g. location.name="Greece" + address.country="GR" would become 48 + // "Greece, GR" without this check. 49 + const countryCode = normalizeCountry(address?.country); 50 + const primaryCountryCode = normalizeCountry(primaryLabel); 51 + if (countryCode && countryCode !== primaryCountryCode) { 52 + appendIfDistinct(countryCode); 53 + } 54 + 40 55 return parts.join(", "); 41 56 }
+22 -2
server/xrpc/getLocations.ts
··· 61 61 return null; 62 62 } 63 63 64 + const regionNames = (() => { 65 + try { 66 + return new Intl.DisplayNames(["en"], { type: "region" }); 67 + } catch { 68 + return null; 69 + } 70 + })(); 71 + 64 72 function computeDisplayName(r: Row): string | null { 65 - const parts = [r.locality, r.region, r.country].map((s) => s?.trim() || null).filter(Boolean); 66 - if (parts.length) return parts.join(", "); 73 + const locality = r.locality?.trim() || null; 74 + const region = r.region?.trim() || null; 75 + const country = normalizeCountry(r.country); 76 + 77 + if (locality || region) { 78 + // Multi-part: keep ISO-2 for the country so all rows in a group share 79 + // the same display (prevents "Portland, Oregon, USA" vs "...US" split). 80 + const parts = [locality, region, country].filter(Boolean); 81 + return parts.length ? parts.join(", ") : null; 82 + } 83 + if (country) { 84 + // Country-only: expand to full name since "GR" alone means nothing to users. 85 + return regionNames?.of(country) ?? country; 86 + } 67 87 return r.name?.trim() || null; 68 88 } 69 89
+7 -2
test/formatLocation.test.ts
··· 56 56 ).toBe("Kansas City, Missouri, US"); 57 57 }); 58 58 59 - test("deep Nominatim fallback for a street + district + city", () => { 59 + test("deep Nominatim fallback normalises non-ISO country variant", () => { 60 60 expect( 61 61 formatStoredLocation( 62 62 { name: "821 Southeast 14th Avenue, Central Eastside, Buckman, Portland, Multnomah County, Oregon, 97214, United States" }, 63 63 { locality: "Portland", region: "Oregon", country: "USA" }, 64 64 ), 65 - ).toBe("821 Southeast 14th Avenue, Portland, Oregon, USA"); 65 + ).toBe("821 Southeast 14th Avenue, Portland, Oregon, US"); 66 + }); 67 + 68 + test("primary label equals country — suppresses redundant country tail", () => { 69 + expect(formatStoredLocation({ name: "Greece" }, { country: "GR" })).toBe("Greece"); 70 + expect(formatStoredLocation({ name: "United States" }, { country: "US" })).toBe("United States"); 66 71 }); 67 72 68 73 test("name already has state abbrev — doesn't duplicate", () => {