iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

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

feat: use server-computed locationDisplay and fix county leak

Reads the new locationDisplay field from gallery and story views so the
UI renders one pre-formatted string instead of piecing together name and
address fields client-side. Falls back to location.name when an older
server hasn't shipped the field yet.

Also fixes two legacy issues that left county embedded in stored records:

- LocationServices.reverseGeocode no longer appends "county" to the
fallback name when Nominatim omits a POI name, matching the web's
geocoder output. New records won't have county in location.name.
- BlueskyPost.buildPostText handles legacy community.lexicon.location.
hthree records (no structured address) by using location.name as-is
instead of stripping after the first comma.

Adds NominatimResultTests covering the county exclusion and extends
BlueskyPostTests with legacy-record, county-embedded, and state-abbrev
cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+142 -32
+2 -1
Grain/Models/Views/GalleryModels.swift
··· 9 9 var cameras: [String]? 10 10 var location: H3Location? 11 11 var address: Address? 12 + var locationDisplay: String? 12 13 var facets: [Facet]? 13 14 let creator: GrainProfile 14 15 var record: AnyCodable? ··· 27 28 } 28 29 29 30 private enum CodingKeys: String, CodingKey { 30 - case uri, cid, title, description, cameras, location, address, facets, creator, record, items, favCount, commentCount, labels, createdAt, indexedAt, viewer, crossPost 31 + case uri, cid, title, description, cameras, location, address, locationDisplay, facets, creator, record, items, favCount, commentCount, labels, createdAt, indexedAt, viewer, crossPost 31 32 } 32 33 } 33 34
+1
Grain/Models/Views/StoryModels.swift
··· 10 10 let aspectRatio: AspectRatio 11 11 var location: H3Location? 12 12 var address: Address? 13 + var locationDisplay: String? 13 14 let createdAt: String 14 15 var labels: [ATLabel]? 15 16 var expired: Bool?
+23 -14
Grain/Utilities/BlueskyPost.swift
··· 138 138 var locationLine: String? 139 139 if let location { 140 140 let trimmedName = location.name.trimmingCharacters(in: .whitespaces) 141 - let primaryLabel = trimmedName.components(separatedBy: ",").first? 142 - .trimmingCharacters(in: .whitespaces) ?? trimmedName 141 + let locality = location.address?["locality"]?.stringValue 142 + let region = location.address?["region"]?.stringValue 143 + let country = location.address?["country"]?.stringValue 144 + let hasAddressContext = [locality, region, country].contains { !($0 ?? "").isEmpty } 145 + 146 + if !hasAddressContext { 147 + // Legacy records (community.lexicon.location.hthree) have no 148 + // structured address — use the stored name as-is. 149 + locationLine = "📍 \(trimmedName)" 150 + } else { 151 + let primaryLabel = trimmedName.components(separatedBy: ",").first? 152 + .trimmingCharacters(in: .whitespaces) ?? trimmedName 143 153 144 - var parts: [String] = [] 145 - func appendIfDistinct(_ value: String?) { 146 - guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return } 147 - if parts.last?.caseInsensitiveCompare(value) == .orderedSame { return } 148 - parts.append(value) 149 - } 154 + var parts: [String] = [] 155 + func appendIfDistinct(_ value: String?) { 156 + guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return } 157 + if parts.last?.caseInsensitiveCompare(value) == .orderedSame { return } 158 + parts.append(value) 159 + } 150 160 151 - appendIfDistinct(primaryLabel) 152 - if let address = location.address { 153 - appendIfDistinct(address["locality"]?.stringValue) 154 - appendIfDistinct(address["region"]?.stringValue) 155 - appendIfDistinct(address["country"]?.stringValue) 161 + appendIfDistinct(primaryLabel) 162 + appendIfDistinct(locality) 163 + appendIfDistinct(region) 164 + appendIfDistinct(country) 165 + locationLine = "📍 \(parts.joined(separator: ", "))" 156 166 } 157 - locationLine = "📍 \(parts.joined(separator: ", "))" 158 167 } 159 168 160 169 // Build suffix (location + hashtag + link)
-2
Grain/Utilities/LocationServices.swift
··· 29 29 let addr = json["address"] as? [String: Any] 30 30 let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String 31 31 32 - let county = addr?["county"] as? String 33 32 var locationParts: [String] = [] 34 33 if let city { locationParts.append(city) } 35 - if let county { locationParts.append(county) } 36 34 if let state = addr?["state"] as? String { locationParts.append(state) } 37 35 if let country = addr?["country"] as? String { locationParts.append(country) } 38 36
+3 -1
Grain/Views/Components/GalleryCardView.swift
··· 284 284 .foregroundStyle(.secondary) 285 285 .fixedSize() 286 286 } 287 - if let location = gallery.location, let locationName = location.name ?? gallery.address?.locality { 287 + if let location = gallery.location, 288 + let locationName = gallery.locationDisplay ?? location.name ?? gallery.address?.locality 289 + { 288 290 Text(locationName) 289 291 .font(.caption) 290 292 .foregroundStyle(.secondary)
+3 -14
Grain/Views/Stories/StoryViewer.swift
··· 1014 1014 } 1015 1015 1016 1016 private func storyLocationText(_ story: GrainStory) -> String? { 1017 - if let name = story.location?.name, !name.isEmpty { 1018 - return name 1019 - } 1020 - if let address = story.address { 1021 - var parts: [String] = [] 1022 - if let name = address.name { parts.append(name) } 1023 - else if let street = address.street { parts.append(street) } 1024 - else if let locality = address.locality { parts.append(locality) } 1025 - if let region = address.region, region != parts.first { parts.append(region) } 1026 - if let locality = address.locality, !parts.contains(locality) { parts.append(locality) } 1027 - if parts.isEmpty { parts.append(address.country) } 1028 - return parts.joined(separator: ", ") 1029 - } 1030 - return nil 1017 + if let display = story.locationDisplay, !display.isEmpty { return display } 1018 + if let name = story.location?.name, !name.isEmpty { return name } 1019 + return story.address?.locality ?? story.address?.country 1031 1020 } 1032 1021 1033 1022 // MARK: - Comments & Likes
+35
GrainTests/BlueskyPostTests.swift
··· 96 96 XCTAssertFalse(text.contains("WA, WA")) 97 97 } 98 98 99 + /// Real story from moll.blue: `name` contains county baked in by older client. 100 + /// Must render as "Kansas City, Missouri, US" — no county, no duplication. 101 + func testBuildPostText_NameWithEmbeddedCounty_SkipsCounty() { 102 + let text = BlueskyPost.buildPostText( 103 + url: url, 104 + title: nil, 105 + location: ( 106 + name: "Kansas City, Jackson County, Missouri, United States", 107 + address: [ 108 + "locality": AnyCodable("Kansas City"), 109 + "region": AnyCodable("Missouri"), 110 + "country": AnyCodable("US"), 111 + ] 112 + ), 113 + description: nil 114 + ) 115 + 116 + XCTAssertTrue(text.contains("📍 Kansas City, Missouri, US"), "Got: \(text)") 117 + XCTAssertFalse(text.contains("County"), "Got: \(text)") 118 + } 119 + 99 120 /// POI where region differs from the primary label. 100 121 func testBuildPostText_CityWithDistinctRegion_IncludesRegion() { 101 122 let text = BlueskyPost.buildPostText( ··· 125 146 ) 126 147 127 148 XCTAssertTrue(text.hasPrefix("Sunset at the Beach, A lovely evening")) 149 + } 150 + 151 + /// Legacy community.lexicon.location.hthree records have no address. 152 + /// Name should be preserved as-is — don't strip context after first comma. 153 + func testBuildPostText_LegacyHthreeRecord_PreservesFullName() { 154 + let text = BlueskyPost.buildPostText( 155 + url: url, 156 + title: nil, 157 + location: (name: "Eindhoven, North Brabant, Netherlands", address: nil), 158 + description: nil 159 + ) 160 + 161 + XCTAssertTrue(text.contains("📍 Eindhoven, North Brabant, Netherlands"), 162 + "Got: \(text)") 128 163 } 129 164 130 165 func testBuildPostText_NoLocationNoContent_ProducesCleanSuffix() {
+75
GrainTests/NominatimResultTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class NominatimResultTests: XCTestCase { 5 + /// Reverse-geocoding a spot in Kansas City returns a `county` field. It must not 6 + /// leak into the stored `name` — we want "Kansas City, Missouri, United States", 7 + /// not "Kansas City, Jackson County, Missouri, United States". 8 + func testReverseGeocode_KansasCity_ExcludesCounty() { 9 + let json: [String: Any] = [ 10 + "place_id": 12345, 11 + "lat": "39.0997", 12 + "lon": "-94.5786", 13 + "display_name": "Kansas City, Jackson County, Missouri, 64108, United States", 14 + "address": [ 15 + "city": "Kansas City", 16 + "county": "Jackson County", 17 + "state": "Missouri", 18 + "country": "United States", 19 + "country_code": "us", 20 + "postcode": "64108", 21 + ], 22 + ] 23 + 24 + let result = NominatimResult(from: json) 25 + XCTAssertNotNil(result) 26 + XCTAssertEqual(result?.name, "Kansas City, Missouri, United States") 27 + XCTAssertFalse(result?.name.contains("County") ?? true, 28 + "Name must not include county") 29 + } 30 + 31 + /// POI with a `name` from Nominatim should use that name as-is. 32 + func testReverseGeocode_POI_UsesPlaceName() { 33 + let json: [String: Any] = [ 34 + "place_id": 54321, 35 + "lat": "37.7749", 36 + "lon": "-122.4194", 37 + "name": "Blue Bottle Coffee", 38 + "display_name": "Blue Bottle Coffee, Mint Plaza, San Francisco, California, United States", 39 + "address": [ 40 + "amenity": "Blue Bottle Coffee", 41 + "city": "San Francisco", 42 + "county": "San Francisco County", 43 + "state": "California", 44 + "country": "United States", 45 + "country_code": "us", 46 + ], 47 + ] 48 + 49 + let result = NominatimResult(from: json) 50 + XCTAssertEqual(result?.name, "Blue Bottle Coffee") 51 + } 52 + 53 + /// Structured address dict must contain locality/region/country — never county. 54 + func testReverseGeocode_AddressExcludesCounty() { 55 + let json: [String: Any] = [ 56 + "place_id": 12345, 57 + "lat": "39.0997", 58 + "lon": "-94.5786", 59 + "display_name": "anything", 60 + "address": [ 61 + "city": "Kansas City", 62 + "county": "Jackson County", 63 + "state": "Missouri", 64 + "country": "United States", 65 + "country_code": "us", 66 + ], 67 + ] 68 + 69 + let result = NominatimResult(from: json) 70 + XCTAssertEqual(result?.address?["locality"]?.stringValue, "Kansas City") 71 + XCTAssertEqual(result?.address?["region"]?.stringValue, "Missouri") 72 + XCTAssertEqual(result?.address?["country"]?.stringValue, "US") 73 + XCTAssertNil(result?.address?["county"]) 74 + } 75 + }