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: post to Bluesky cross-posting for galleries and stories

Add "Post to Bluesky" toggle to gallery and story creation forms.
After creating a gallery/story, optionally cross-posts to Bluesky
with location, description (truncated to fit 300 grapheme / 3000 byte
limits), images, and #grainsocial hashtag. Parses facets for URLs,
@mentions (resolved via public Bluesky API), and hashtags. Gallery
creation is never blocked by a cross-post failure.

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

+346 -1
+9
Grain/Utilities/AnyCodable.swift
··· 21 21 case let d as Double: storage = .double(d) 22 22 case let s as String: storage = .string(s) 23 23 case let a as [AnyCodable]: storage = .array(a) 24 + case let a as [String]: 25 + storage = .array(a.map { AnyCodable($0) }) 26 + case let a as [[String: AnyCodable]]: 27 + storage = .array(a.map { AnyCodable($0) }) 24 28 case let d as [String: AnyCodable]: storage = .dict(d) 25 29 case let d as [String: String]: 26 30 storage = .dict(d.mapValues { AnyCodable($0) }) ··· 51 55 52 56 var dictValue: [String: AnyCodable]? { 53 57 if case .dict(let d) = storage { return d } 58 + return nil 59 + } 60 + 61 + var stringValue: String? { 62 + if case .string(let s) = storage { return s } 54 63 return nil 55 64 } 56 65
+280
Grain/Utilities/BlueskyPost.swift
··· 1 + import Foundation 2 + import os 3 + 4 + private let logger = Logger(subsystem: "social.grain.grain", category: "BlueskyPost") 5 + 6 + struct BlueskyPostOptions { 7 + let url: String 8 + let location: (name: String, address: [String: AnyCodable]?)? 9 + let description: String? 10 + let images: [(blob: BlobRef, alt: String, width: Int, height: Int)] 11 + } 12 + 13 + enum BlueskyPost { 14 + 15 + /// Create a cross-post to Bluesky with images, location, and description. 16 + /// Mirrors the web client's `createBskyPost()` in `bsky-post.ts`. 17 + static func create( 18 + options: BlueskyPostOptions, 19 + client: XRPCClient, 20 + repo: String, 21 + auth: AuthContext 22 + ) async throws { 23 + logger.info("Creating Bluesky cross-post for \(options.url)") 24 + logger.info(" images: \(options.images.count), location: \(options.location?.name ?? "none"), description: \(options.description ?? "none")") 25 + 26 + // 1. Build post text (same format as web) 27 + let postText = buildPostText( 28 + url: options.url, 29 + location: options.location, 30 + description: options.description 31 + ) 32 + logger.info(" postText: \(postText)") 33 + 34 + // 2. Parse facets for URLs, mentions, and hashtags (same as web) 35 + let facets = await parseTextToFacets(postText) 36 + logger.info(" facets: \(facets.count)") 37 + 38 + // 3. Build image embed from already-uploaded blob refs 39 + // Web client uploads separately, but blob refs are repo-scoped and reusable 40 + var imageEmbeds: [[String: AnyCodable]] = [] 41 + for img in options.images.prefix(4) { 42 + let blobDict: [String: AnyCodable] = [ 43 + "$type": AnyCodable(img.blob.type ?? "blob"), 44 + "ref": AnyCodable(["$link": AnyCodable(img.blob.ref?.link ?? "")] as [String: AnyCodable]), 45 + "mimeType": AnyCodable(img.blob.mimeType ?? "image/jpeg"), 46 + "size": AnyCodable(img.blob.size ?? 0) 47 + ] 48 + imageEmbeds.append([ 49 + "image": AnyCodable(blobDict), 50 + "alt": AnyCodable(img.alt), 51 + "aspectRatio": AnyCodable([ 52 + "width": AnyCodable(img.width), 53 + "height": AnyCodable(img.height) 54 + ] as [String: AnyCodable]) 55 + ]) 56 + logger.info(" image blob: type=\(img.blob.type ?? "nil"), ref=\(img.blob.ref?.link ?? "nil"), size=\(img.blob.size ?? 0)") 57 + } 58 + 59 + // 4. Build the record — must match web's structure exactly: 60 + // { text, facets?, embed?, tags: ["grainsocial"], createdAt } 61 + var record: [String: AnyCodable] = [ 62 + "text": AnyCodable(postText), 63 + "tags": AnyCodable(["grainsocial"] as [String]), 64 + "createdAt": AnyCodable(DateFormatting.nowISO()) 65 + ] 66 + 67 + if !facets.isEmpty { 68 + let facetDicts: [[String: AnyCodable]] = facets.map { facet in 69 + let featureDicts: [[String: AnyCodable]] = facet.features.map { feature in 70 + switch feature { 71 + case .link(let uri): 72 + return ["$type": AnyCodable("app.bsky.richtext.facet#link"), "uri": AnyCodable(uri)] 73 + case .mention(let did): 74 + return ["$type": AnyCodable("app.bsky.richtext.facet#mention"), "did": AnyCodable(did)] 75 + case .tag(let tag): 76 + return ["$type": AnyCodable("app.bsky.richtext.facet#tag"), "tag": AnyCodable(tag)] 77 + } 78 + } 79 + return [ 80 + "index": AnyCodable([ 81 + "byteStart": AnyCodable(facet.index.byteStart), 82 + "byteEnd": AnyCodable(facet.index.byteEnd) 83 + ] as [String: AnyCodable]), 84 + "features": AnyCodable(featureDicts as [[String: AnyCodable]]) 85 + ] 86 + } 87 + record["facets"] = AnyCodable(facetDicts as [[String: AnyCodable]]) 88 + } 89 + 90 + if !imageEmbeds.isEmpty { 91 + record["embed"] = AnyCodable([ 92 + "$type": AnyCodable("app.bsky.embed.images"), 93 + "images": AnyCodable(imageEmbeds as [[String: AnyCodable]]) 94 + ] as [String: AnyCodable]) 95 + } 96 + 97 + // 5. Log the full JSON for debugging 98 + if let jsonData = try? JSONEncoder().encode(AnyCodable(record)), 99 + let jsonStr = String(data: jsonData, encoding: .utf8) { 100 + logger.info(" record JSON: \(jsonStr)") 101 + } 102 + 103 + // 6. Create the record 104 + logger.info(" calling dev.hatk.createRecord with collection=app.bsky.feed.post, repo=\(repo)") 105 + let result = try await client.createRecord( 106 + collection: "app.bsky.feed.post", 107 + repo: repo, 108 + record: AnyCodable(record), 109 + auth: auth 110 + ) 111 + 112 + logger.info("Bluesky cross-post created: uri=\(result.uri ?? "nil"), cid=\(result.cid ?? "nil")") 113 + } 114 + 115 + // MARK: - Text Building 116 + 117 + /// Build post text matching web format: 118 + /// 📍 Location Name 119 + /// Locality, Region, Country 120 + /// 121 + /// Description (truncated to fit 300 graphemes) 122 + /// 123 + /// https://grain.social/profile/did/gallery/rkey 124 + /// 125 + /// #grainsocial 126 + static func buildPostText( 127 + url: String, 128 + location: (name: String, address: [String: AnyCodable]?)?, 129 + description: String? 130 + ) -> String { 131 + var lines: [String] = [] 132 + 133 + if let location { 134 + lines.append("📍 \(location.name)") 135 + if let address = location.address { 136 + var parts: [String] = [] 137 + if let locality = address["locality"]?.stringValue { parts.append(locality) } 138 + if let region = address["region"]?.stringValue { parts.append(region) } 139 + if let country = address["country"]?.stringValue { parts.append(country) } 140 + if !parts.isEmpty { 141 + lines.append(parts.joined(separator: ", ")) 142 + } 143 + } 144 + } 145 + 146 + // 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 154 + 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))) + "…" 160 + } 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) 168 + } 169 + } 170 + 171 + lines.append("") 172 + lines.append(url) 173 + lines.append("") 174 + lines.append("#grainsocial") 175 + 176 + return lines.joined(separator: "\n") 177 + } 178 + 179 + // MARK: - Facet Parsing 180 + 181 + /// Parse URLs, mentions, and hashtags into Bluesky facets with byte offsets. 182 + /// Matches web's `parseTextToFacets()` in `rich-text.ts` — same regex patterns, 183 + /// same priority order (URLs > mentions > hashtags), same byte offset calculation. 184 + static func parseTextToFacets(_ text: String) async -> [Facet] { 185 + guard !text.isEmpty else { return [] } 186 + 187 + var facets: [Facet] = [] 188 + var claimed = Set<Int>() 189 + 190 + func byteOffset(for charIndex: String.Index) -> Int { 191 + text.utf8.distance(from: text.utf8.startIndex, to: charIndex) 192 + } 193 + 194 + func isRangeClaimed(_ start: Int, _ end: Int) -> Bool { 195 + for i in start..<end where claimed.contains(i) { return true } 196 + return false 197 + } 198 + 199 + func claimRange(_ start: Int, _ end: Int) { 200 + for i in start..<end { claimed.insert(i) } 201 + } 202 + 203 + let nsText = text as NSString 204 + 205 + // URLs (highest priority, same as web) 206 + let urlPattern = try! NSRegularExpression(pattern: #"https?://[^\s<>\[\]()]+"#) 207 + for match in urlPattern.matches(in: text, range: NSRange(location: 0, length: nsText.length)) { 208 + guard let range = Range(match.range, in: text) else { continue } 209 + let byteStart = byteOffset(for: range.lowerBound) 210 + let byteEnd = byteOffset(for: range.upperBound) 211 + guard !isRangeClaimed(byteStart, byteEnd) else { continue } 212 + claimRange(byteStart, byteEnd) 213 + facets.append(Facet( 214 + index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd), 215 + features: [.link(uri: String(text[range]))] 216 + )) 217 + } 218 + 219 + // Mentions (same regex as web — resolve handle to DID via public Bluesky API) 220 + let mentionPattern = try! NSRegularExpression( 221 + pattern: #"@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"# 222 + ) 223 + for match in mentionPattern.matches(in: text, range: NSRange(location: 0, length: nsText.length)) { 224 + guard let range = Range(match.range, in: text) else { continue } 225 + let byteStart = byteOffset(for: range.lowerBound) 226 + let byteEnd = byteOffset(for: range.upperBound) 227 + guard !isRangeClaimed(byteStart, byteEnd) else { continue } 228 + 229 + let fullMatch = String(text[range]) 230 + let handle = String(fullMatch.dropFirst()) // remove @ 231 + if let did = await resolveHandle(handle) { 232 + claimRange(byteStart, byteEnd) 233 + facets.append(Facet( 234 + index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd), 235 + features: [.mention(did: did)] 236 + )) 237 + } 238 + } 239 + 240 + // Hashtags (same regex as web) 241 + let hashtagPattern = try! NSRegularExpression(pattern: #"#([a-zA-Z][a-zA-Z0-9_]*)"#) 242 + for match in hashtagPattern.matches(in: text, range: NSRange(location: 0, length: nsText.length)) { 243 + guard let fullRange = Range(match.range, in: text), 244 + let tagRange = Range(match.range(at: 1), in: text) else { continue } 245 + let byteStart = byteOffset(for: fullRange.lowerBound) 246 + let byteEnd = byteOffset(for: fullRange.upperBound) 247 + guard !isRangeClaimed(byteStart, byteEnd) else { continue } 248 + claimRange(byteStart, byteEnd) 249 + facets.append(Facet( 250 + index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd), 251 + features: [.tag(tag: String(text[tagRange]))] 252 + )) 253 + } 254 + 255 + facets.sort { $0.index.byteStart < $1.index.byteStart } 256 + return facets 257 + } 258 + 259 + // MARK: - Handle Resolution 260 + 261 + /// Resolve a Bluesky handle to a DID via the public API. 262 + /// Same as web's `resolveHandle()` in `bsky-post.ts`. 263 + private static func resolveHandle(_ handle: String) async -> String? { 264 + guard let encoded = handle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), 265 + let url = URL(string: "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=\(encoded)") else { 266 + return nil 267 + } 268 + 269 + do { 270 + let (data, response) = try await URLSession.shared.data(from: url) 271 + guard let httpResponse = response as? HTTPURLResponse, 272 + (200...299).contains(httpResponse.statusCode) else { return nil } 273 + let json = try JSONDecoder().decode([String: String].self, from: data) 274 + return json["did"] 275 + } catch { 276 + logger.debug("Failed to resolve handle @\(handle): \(error)") 277 + return nil 278 + } 279 + } 280 + }
+29
Grain/Views/Create/CreateGalleryView.swift
··· 22 22 @State private var showCamera = false 23 23 @State private var photoItems: [PhotoItem] = [] 24 24 @State private var mentionState = MentionAutocompleteState() 25 + @State private var postToBluesky = false 25 26 26 27 let client: XRPCClient 27 28 var onCreated: (() -> Void)? ··· 144 145 } 145 146 } 146 147 } 148 + } 149 + 150 + Section { 151 + Toggle("Post to Bluesky", isOn: $postToBluesky) 147 152 } 148 153 149 154 if let errorMessage { ··· 374 379 record: AnyCodable(itemRecord), 375 380 auth: authContext 376 381 ) 382 + } 383 + } 384 + 385 + // Cross-post to Bluesky if toggled 386 + if postToBluesky, let galleryUri = galleryResult.uri { 387 + let rkey = galleryUri.split(separator: "/").last.map(String.init) ?? "" 388 + let postURL = "https://grain.social/profile/\(repo)/gallery/\(rkey)" 389 + let bskyImages = zip(processed, altTexts).map { photo, alt in 390 + (blob: photo.blob, alt: alt, width: photo.aspectRatio.width, height: photo.aspectRatio.height) 391 + } 392 + do { 393 + try await BlueskyPost.create( 394 + options: BlueskyPostOptions( 395 + url: postURL, 396 + location: resolvedLocation.map { ($0.name, $0.address) }, 397 + description: description.isEmpty ? nil : description, 398 + images: bskyImages 399 + ), 400 + client: client, 401 + repo: repo, 402 + auth: authContext 403 + ) 404 + } catch { 405 + logger.error("Bluesky cross-post failed: \(error)") 377 406 } 378 407 } 379 408
+28 -1
Grain/Views/Stories/StoryCreateView.swift
··· 19 19 @State private var locationSearchTask: Task<Void, Never>? 20 20 @State private var isUploading = false 21 21 @State private var errorMessage: String? 22 + @State private var postToBluesky = false 22 23 23 24 var body: some View { 24 25 NavigationStack { ··· 96 97 } 97 98 } 98 99 } 100 + } 101 + 102 + Section { 103 + Toggle("Post to Bluesky", isOn: $postToBluesky) 99 104 } 100 105 101 106 if let errorMessage { ··· 213 218 } 214 219 } 215 220 216 - _ = try await client.createRecord( 221 + let storyResult = try await client.createRecord( 217 222 collection: "social.grain.story", 218 223 repo: repo, 219 224 record: AnyCodable(record), 220 225 auth: authContext 221 226 ) 227 + 228 + // Cross-post to Bluesky if toggled 229 + if postToBluesky, let storyUri = storyResult.uri { 230 + let rkey = storyUri.split(separator: "/").last.map(String.init) ?? "" 231 + let postURL = "https://grain.social/profile/\(repo)/story/\(rkey)" 232 + do { 233 + let location: (name: String, address: [String: AnyCodable]?)? = resolvedLocation.map { ($0.name, $0.address) } 234 + try await BlueskyPost.create( 235 + options: BlueskyPostOptions( 236 + url: postURL, 237 + location: location, 238 + description: nil as String?, 239 + images: [(blob: response.blob, alt: "", width: Int(size.width), height: Int(size.height))] 240 + ), 241 + client: client, 242 + repo: repo, 243 + auth: authContext 244 + ) 245 + } catch { 246 + // Don't fail the story creation if cross-post fails 247 + } 248 + } 222 249 223 250 onCreated?() 224 251 dismiss()