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: update Bluesky cross-post format with title and shortened location

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

+53 -40
+47 -38
Grain/Utilities/BlueskyPost.swift
··· 5 5 6 6 struct BlueskyPostOptions { 7 7 let url: String 8 + let title: String? 8 9 let location: (name: String, address: [String: AnyCodable]?)? 9 10 let description: String? 10 11 let images: [(blob: BlobRef, alt: String, width: Int, height: Int)] ··· 25 26 // 1. Build post text (same format as web) 26 27 let postText = buildPostText( 27 28 url: options.url, 29 + title: options.title, 28 30 location: options.location, 29 31 description: options.description 30 32 ) ··· 114 116 115 117 // MARK: - Text Building 116 118 117 - /// Build post text matching web format: 118 - /// 📍 Location Name 119 - /// Locality, Region, Country 119 + /// Build post text: 120 + /// Title, description… 120 121 /// 121 - /// Description (truncated to fit 300 graphemes) 122 + /// 📍 Location Name, Region, Country 122 123 /// 123 - /// https://grain.social/profile/did/gallery/rkey 124 - /// 125 - /// #grainsocial 124 + /// #GrainSocial see full post here (link) 126 125 static func buildPostText( 127 126 url: String, 127 + title: String?, 128 128 location: (name: String, address: [String: AnyCodable]?)?, 129 129 description: String? 130 130 ) -> String { 131 - var lines: [String] = [] 132 - 131 + // Build location line (shortened: name, region, country) 132 + var locationLine: String? 133 133 if let location { 134 - lines.append("📍 \(location.name)") 134 + var parts = [location.name] 135 135 if let address = location.address { 136 - var parts: [String] = [] 137 - if let locality = address["locality"]?.stringValue { parts.append(locality) } 138 136 if let region = address["region"]?.stringValue { parts.append(region) } 139 137 if let country = address["country"]?.stringValue { parts.append(country) } 140 - if !parts.isEmpty { 141 - lines.append(parts.joined(separator: ", ")) 142 - } 143 138 } 139 + locationLine = "📍 \(parts.joined(separator: ", "))" 144 140 } 145 141 142 + // Build suffix (location + hashtag + link) 143 + var suffixLines: [String] = [] 144 + if let locationLine { 145 + suffixLines.append("") 146 + suffixLines.append(locationLine) 147 + } 148 + suffixLines.append("") 149 + suffixLines.append("#GrainSocial \(url)") 150 + let suffix = suffixLines.joined(separator: "\n") 151 + 146 152 // Lexicon constraints: maxGraphemes=300, maxLength=3000 bytes 147 - // Swift .count is grapheme count (same as Intl.Segmenter) 148 - let suffix = "\n\n\(url)\n\n#grainsocial" 149 - let prefixText = lines.isEmpty ? "" : lines.joined(separator: "\n") + "\n" 150 - let overheadGraphemes = (prefixText + suffix).count 151 - let overheadBytes = (prefixText + suffix).utf8.count 152 - let maxDescGraphemes = 300 - overheadGraphemes 153 - let maxDescBytes = 3000 - overheadBytes 153 + let overheadGraphemes = suffix.count 154 + let overheadBytes = suffix.utf8.count 155 + let maxContentGraphemes = 300 - overheadGraphemes 156 + let maxContentBytes = 3000 - overheadBytes 157 + 158 + // Build title + description content 159 + var content = "" 160 + let titleText = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 161 + let descText = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 162 + 163 + if !titleText.isEmpty, !descText.isEmpty { 164 + content = "\(titleText), \(descText)" 165 + } else if !titleText.isEmpty { 166 + content = titleText 167 + } else if !descText.isEmpty { 168 + content = descText 169 + } 154 170 155 - if let desc = description?.trimmingCharacters(in: .whitespacesAndNewlines), !desc.isEmpty { 156 - var truncated = desc 157 - // Truncate to fit grapheme limit 158 - if truncated.count > maxDescGraphemes { 159 - truncated = String(truncated.prefix(max(0, maxDescGraphemes - 1))) + "…" 171 + // Truncate to fit 172 + if !content.isEmpty { 173 + if content.count > maxContentGraphemes { 174 + content = String(content.prefix(max(0, maxContentGraphemes - 1))) + "…" 160 175 } 161 - // Truncate further to fit byte limit 162 - while truncated.utf8.count > maxDescBytes, !truncated.isEmpty { 163 - truncated = String(truncated.dropLast(2)) + "…" 164 - } 165 - if !truncated.isEmpty { 166 - lines.append("") 167 - lines.append(truncated) 176 + while content.utf8.count > maxContentBytes, !content.isEmpty { 177 + content = String(content.dropLast(2)) + "…" 168 178 } 169 179 } 170 180 171 - lines.append("") 172 - lines.append(url) 173 - lines.append("") 174 - lines.append("#grainsocial") 181 + var lines: [String] = [] 182 + if !content.isEmpty { lines.append(content) } 183 + lines.append(contentsOf: suffixLines) 175 184 176 185 return lines.joined(separator: "\n") 177 186 }
+2 -1
Grain/Views/Create/CreateGalleryView.swift
··· 114 114 Section { 115 115 Toggle("Post to Bluesky", isOn: $postToBluesky) 116 116 } footer: { 117 - Text("Includes location, description, and the first 4 photos.") 117 + Text("Includes title, description, location, and the first 4 photos.") 118 118 } 119 119 errorSection 120 120 } ··· 532 532 try await BlueskyPost.create( 533 533 options: BlueskyPostOptions( 534 534 url: postURL, 535 + title: title.isEmpty ? nil : title, 535 536 location: resolvedLocation.map { ($0.name, $0.address) }, 536 537 description: description.isEmpty ? nil : description, 537 538 images: bskyImages
+4 -1
Grain/Views/Stories/StoryCreateView.swift
··· 55 55 56 56 Section { 57 57 Toggle("Post to Bluesky", isOn: $postToBluesky) 58 + } footer: { 59 + Text("Includes location and photo.") 58 60 } 59 61 60 62 if let errorMessage { ··· 205 207 try await BlueskyPost.create( 206 208 options: BlueskyPostOptions( 207 209 url: postURL, 210 + title: nil, 208 211 location: location, 209 - description: nil as String?, 212 + description: nil, 210 213 images: [(blob: response.blob, alt: "", width: Int(size.width), height: Int(size.height))] 211 214 ), 212 215 client: client,