~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
357 lines · 15.2 KB Raw History
import Foundation
import os

private let logger = Logger(subsystem: "social.grain.grain", category: "BlueskyPost")

/// Facet regexes, compiled once. The patterns are literals so they can't
/// actually fail to compile — `try?` rather than `try!` keeps the linter happy,
/// and a nil here just means that facet type is skipped.
private enum FacetPattern {
    static let url = try? NSRegularExpression(pattern: #"https?://[^\s<>\[\]()]+"#)
    static let mention = try? NSRegularExpression(
        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])?"#
    )
    static let hashtag = try? NSRegularExpression(pattern: #"#(\p{L}[\p{L}\p{N}_]*)"#)
}

struct BlueskyPostOptions {
    let url: String
    let title: String?
    let location: (name: String, address: [String: AnyCodable]?)?
    let description: String?
    let images: [(blob: BlobRef, alt: String, width: Int, height: Int)]
}

enum BlueskyPost {
    /// Create a cross-post to Bluesky with images, location, and description.
    /// Mirrors the web client's `createBskyPost()` in `bsky-post.ts`.
    ///
    /// Pass `rkey` when the caller might run this more than once — a resumed
    /// gallery publish, say. With a record key of our own the write becomes an
    /// overwrite instead of a second post on the user's timeline.
    static func create(
        options: BlueskyPostOptions,
        client: XRPCClient,
        repo: String,
        auth: AuthContext,
        rkey: String? = nil,
        createdAt: String = DateFormatting.nowISO()
    ) async throws {
        logger.info("Creating Bluesky cross-post for \(options.url)")
        logger.info("  images: \(options.images.count), location: \(options.location?.name ?? "none"), description: \(options.description ?? "none")")

        // 1. Build post text (same format as web)
        let postText = buildPostText(
            url: options.url,
            title: options.title,
            location: options.location,
            description: options.description
        )
        logger.info("  postText: \(postText)")

        // 2. Parse facets for URLs, mentions, and hashtags (same as web)
        let facets = await parseTextToFacets(postText)
        logger.info("  facets: \(facets.count)")

        // 3. Build image embed from already-uploaded blob refs
        // Web client uploads separately, but blob refs are repo-scoped and reusable
        var imageEmbeds: [[String: AnyCodable]] = []
        for img in options.images.prefix(4) {
            let blobDict: [String: AnyCodable] = [
                "$type": AnyCodable(img.blob.type ?? "blob"),
                "ref": AnyCodable(["$link": AnyCodable(img.blob.ref?.link ?? "")] as [String: AnyCodable]),
                "mimeType": AnyCodable(img.blob.mimeType ?? "image/jpeg"),
                "size": AnyCodable(img.blob.size ?? 0),
            ]
            imageEmbeds.append([
                "image": AnyCodable(blobDict),
                "alt": AnyCodable(img.alt),
                "aspectRatio": AnyCodable([
                    "width": AnyCodable(img.width),
                    "height": AnyCodable(img.height),
                ] as [String: AnyCodable]),
            ])
            logger.info("  image blob: type=\(img.blob.type ?? "nil"), ref=\(img.blob.ref?.link ?? "nil"), size=\(img.blob.size ?? 0)")
        }

        // 4. Build the record — must match web's structure exactly:
        //    { text, facets?, embed?, tags: ["grainsocial"], createdAt }
        var record: [String: AnyCodable] = [
            "text": AnyCodable(postText),
            "tags": AnyCodable(["grainsocial"] as [String]),
            "createdAt": AnyCodable(createdAt),
        ]

        if !facets.isEmpty {
            let facetDicts: [[String: AnyCodable]] = facets.map { facet in
                let featureDicts: [[String: AnyCodable]] = facet.features.map { feature in
                    switch feature {
                    case let .link(uri):
                        ["$type": AnyCodable("app.bsky.richtext.facet#link"), "uri": AnyCodable(uri)]
                    case let .mention(did):
                        ["$type": AnyCodable("app.bsky.richtext.facet#mention"), "did": AnyCodable(did)]
                    case let .tag(tag):
                        ["$type": AnyCodable("app.bsky.richtext.facet#tag"), "tag": AnyCodable(tag)]
                    }
                }
                return [
                    "index": AnyCodable([
                        "byteStart": AnyCodable(facet.index.byteStart),
                        "byteEnd": AnyCodable(facet.index.byteEnd),
                    ] as [String: AnyCodable]),
                    "features": AnyCodable(featureDicts as [[String: AnyCodable]]),
                ]
            }
            record["facets"] = AnyCodable(facetDicts as [[String: AnyCodable]])
        }

        if !imageEmbeds.isEmpty {
            record["embed"] = AnyCodable([
                "$type": AnyCodable("app.bsky.embed.images"),
                "images": AnyCodable(imageEmbeds as [[String: AnyCodable]]),
            ] as [String: AnyCodable])
        }

        // 5. Log the full JSON for debugging
        if let jsonData = try? JSONEncoder().encode(AnyCodable(record)),
           let jsonStr = String(data: jsonData, encoding: .utf8)
        {
            logger.info("  record JSON: \(jsonStr)")
        }

        // 6. Write the record
        let result: CreateRecordResponse
        if let rkey {
            logger.info("  calling com.atproto.repo.putRecord with collection=app.bsky.feed.post, repo=\(repo), rkey=\(rkey)")
            result = try await client.putRecord(
                collection: "app.bsky.feed.post",
                rkey: rkey,
                record: AnyCodable(record),
                repo: repo,
                auth: auth
            )
        } else {
            logger.info("  calling com.atproto.repo.createRecord with collection=app.bsky.feed.post, repo=\(repo)")
            result = try await client.createRecord(
                collection: "app.bsky.feed.post",
                repo: repo,
                record: AnyCodable(record),
                auth: auth
            )
        }

        logger.info("Bluesky cross-post created: uri=\(result.uri ?? "nil"), cid=\(result.cid ?? "nil")")
    }

    // MARK: - Text Building

    /// Build post text:
    /// Title, description…
    ///
    /// 📍 Location Name, Region, Country
    ///
    /// #GrainSocial see full post here (link)
    static func buildPostText(
        url: String,
        title: String?,
        location: (name: String, address: [String: AnyCodable]?)?,
        description: String?
    ) -> String {
        // Build location line. `location.name` may be either a POI name
        // ("Blue Bottle Coffee") or a Nominatim-formatted fallback that already
        // contains locality/state/country ("New York, New York, United States").
        // We take the first comma-separated chunk as the primary label, then
        // append locality/region/country while dropping adjacent duplicates —
        // this keeps useful context for POIs ("Blue Bottle Coffee, Oakland, California, US")
        // while collapsing duplicates for city fallbacks ("New York, US").
        var locationLine: String?
        if let location {
            let trimmedName = location.name.trimmingCharacters(in: .whitespaces)
            let locality = location.address?["locality"]?.stringValue
            let region = location.address?["region"]?.stringValue
            let country = location.address?["country"]?.stringValue
            let hasAddressContext = [locality, region, country].contains { !($0 ?? "").isEmpty }

            if !hasAddressContext {
                // Legacy records (community.lexicon.location.hthree) have no
                // structured address — use the stored name as-is.
                locationLine = "📍 \(trimmedName)"
            } else {
                let primaryLabel = trimmedName.components(separatedBy: ",").first?
                    .trimmingCharacters(in: .whitespaces) ?? trimmedName

                var parts: [String] = []
                func appendIfDistinct(_ value: String?) {
                    guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return }
                    if parts.last?.caseInsensitiveCompare(value) == .orderedSame {
                        return
                    }
                    parts.append(value)
                }

                appendIfDistinct(primaryLabel)
                appendIfDistinct(locality)
                appendIfDistinct(region)
                appendIfDistinct(country)
                locationLine = "📍 \(parts.joined(separator: ", "))"
            }
        }

        // Build suffix (location + hashtag + link)
        var suffixLines: [String] = []
        if let locationLine {
            suffixLines.append("")
            suffixLines.append(locationLine)
        }
        suffixLines.append("")
        suffixLines.append("#GrainSocial \(url)")
        let suffix = suffixLines.joined(separator: "\n")

        // Lexicon constraints: maxGraphemes=300, maxLength=3000 bytes.
        // The budget has to pay for the newline that joins the content to the
        // suffix as well as the suffix itself — without it a truncated post
        // lands on exactly 301 graphemes and the PDS rejects the record.
        let overheadGraphemes = suffix.count + 1
        let overheadBytes = suffix.utf8.count + 1
        let maxContentGraphemes = 300 - overheadGraphemes
        let maxContentBytes = 3000 - overheadBytes

        // Build title + description content
        var content = ""
        let titleText = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        let descText = description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

        if !titleText.isEmpty, !descText.isEmpty {
            content = "\(titleText), \(descText)"
        } else if !titleText.isEmpty {
            content = titleText
        } else if !descText.isEmpty {
            content = descText
        }

        // Truncate to fit. A budget too small to hold even the ellipsis means
        // the suffix alone fills the post, so the content is dropped entirely.
        if !content.isEmpty {
            if maxContentGraphemes < 1 || maxContentBytes < 3 {
                content = ""
            } else {
                if content.count > maxContentGraphemes {
                    content = String(content.prefix(maxContentGraphemes - 1)) + "…"
                }
                while content.utf8.count > maxContentBytes, content.count > 1 {
                    content = String(content.dropLast(2)) + "…"
                }
            }
        }

        var lines: [String] = []
        if !content.isEmpty {
            lines.append(content)
        }
        lines.append(contentsOf: suffixLines)

        return lines.joined(separator: "\n")
    }

    // MARK: - Facet Parsing

    /// Parse URLs, mentions, and hashtags into Bluesky facets with byte offsets.
    /// Matches web's `parseTextToFacets()` in `rich-text.ts` — same regex patterns,
    /// same priority order (URLs > mentions > hashtags), same byte offset calculation.
    static func parseTextToFacets(_ text: String) async -> [Facet] {
        guard !text.isEmpty else { return [] }

        var facets: [Facet] = []
        var claimed = Set<Int>()

        func byteOffset(for charIndex: String.Index) -> Int {
            text.utf8.distance(from: text.utf8.startIndex, to: charIndex)
        }

        func isRangeClaimed(_ start: Int, _ end: Int) -> Bool {
            for i in start ..< end where claimed.contains(i) {
                return true
            }
            return false
        }

        func claimRange(_ start: Int, _ end: Int) {
            for i in start ..< end {
                claimed.insert(i)
            }
        }

        let nsText = text as NSString

        // URLs (highest priority, same as web)
        for match in FacetPattern.url?.matches(in: text, range: NSRange(location: 0, length: nsText.length)) ?? [] {
            guard let range = Range(match.range, in: text) else { continue }
            let byteStart = byteOffset(for: range.lowerBound)
            let byteEnd = byteOffset(for: range.upperBound)
            guard !isRangeClaimed(byteStart, byteEnd) else { continue }
            claimRange(byteStart, byteEnd)
            facets.append(Facet(
                index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd),
                features: [.link(uri: String(text[range]))]
            ))
        }

        // Mentions (same regex as web — resolve handle to DID via public Bluesky API)
        for match in FacetPattern.mention?.matches(in: text, range: NSRange(location: 0, length: nsText.length)) ?? [] {
            guard let range = Range(match.range, in: text) else { continue }
            let byteStart = byteOffset(for: range.lowerBound)
            let byteEnd = byteOffset(for: range.upperBound)
            guard !isRangeClaimed(byteStart, byteEnd) else { continue }

            let fullMatch = String(text[range])
            let handle = String(fullMatch.dropFirst()) // remove @
            if let did = await resolveHandle(handle) {
                claimRange(byteStart, byteEnd)
                facets.append(Facet(
                    index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd),
                    features: [.mention(did: did)]
                ))
            }
        }

        // Hashtags (same regex as web)
        for match in FacetPattern.hashtag?.matches(in: text, range: NSRange(location: 0, length: nsText.length)) ?? [] {
            guard let fullRange = Range(match.range, in: text),
                  let tagRange = Range(match.range(at: 1), in: text) else { continue }
            let byteStart = byteOffset(for: fullRange.lowerBound)
            let byteEnd = byteOffset(for: fullRange.upperBound)
            guard !isRangeClaimed(byteStart, byteEnd) else { continue }
            claimRange(byteStart, byteEnd)
            facets.append(Facet(
                index: Facet.ByteSlice(byteStart: byteStart, byteEnd: byteEnd),
                features: [.tag(tag: String(text[tagRange]))]
            ))
        }

        facets.sort { $0.index.byteStart < $1.index.byteStart }
        return facets
    }

    // MARK: - Handle Resolution

    /// Resolve a Bluesky handle to a DID via the public API.
    /// Same as web's `resolveHandle()` in `bsky-post.ts`.
    private static func resolveHandle(_ handle: String) async -> String? {
        guard let encoded = handle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=\(encoded)")
        else {
            return nil
        }

        do {
            let (data, response) = try await NetworkEnvironment.session.data(from: url)
            guard let httpResponse = response as? HTTPURLResponse,
                  (200 ... 299).contains(httpResponse.statusCode) else { return nil }
            let json = try JSONDecoder().decode([String: String].self, from: data)
            return json["did"]
        } catch {
            logger.debug("Failed to resolve handle @\(handle): \(error)")
            return nil
        }
    }
}