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

grain.social/grain-ios

1 collaborator
0
1
main
99 lines · 4.6 KB Raw History
import Foundation
import os

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

/// A grain profile made for the account once it signs in, from its Bluesky
/// profile when it has one, as grain-serve makes one for the web
/// (`login/profile.rs`). The app signs in at the account's own PDS, so
/// grain-serve never sees the sign-in: the app does it, in the background,
/// so a sign-in never waits on it and never fails by it.
///
/// An account whose `social.grain.actor.profile` has a `displayName` is
/// left as it is. Otherwise its `app.bsky.actor.profile` gives the grain
/// profile its `displayName`, `description` and `avatar` (the blob as the
/// Bluesky profile names it), with the `createdAt` of the grain profile
/// there is, or now. One there is is put over, swapped on its CID; else one
/// is made at `self`. An account with no Bluesky profile and no grain
/// profile gets a bare one, its `createdAt` alone, so every account that
/// signs in has the profile that makes grain-serve track it; one with an
/// unnamed grain profile and no Bluesky profile is left as it is. Both are
/// read from the account's PDS, and the write is then indexed.
enum ProfileSeed {
    static let grain = "social.grain.actor.profile"
    static let bluesky = "app.bsky.actor.profile"

    /// What `seed` did.
    enum Outcome: Equatable {
        /// The grain profile has a name already.
        case named
        /// There is no Bluesky profile to name it from, and the grain
        /// profile there is was left as it is.
        case noBluesky
        /// A grain profile was made, from the Bluesky one or bare.
        case made
        /// The grain profile there was was put over.
        case put
    }

    /// Seed the profile, logging only how it failed.
    static func run(client: XRPCClient, auth: AuthContext) async {
        do {
            let outcome = try await seed(client: client, auth: auth)
            logger.info("profile seed: \(String(describing: outcome), privacy: .public)")
        } catch {
            logger.info("seeding a grain profile failed: \(error)")
        }
    }

    static func seed(client: XRPCClient, auth: AuthContext) async throws -> Outcome {
        let existing = try await read(grain, client: client, auth: auth)
        if let name = existing?.value?.dictValue?["displayName"]?.stringValue, !name.isEmpty {
            return .named
        }
        let bsky = try await read(bluesky, client: client, auth: auth)?.value?.dictValue
        if bsky == nil, existing != nil {
            return .noBluesky
        }
        let record = AnyCodable(fromBluesky(bsky ?? [:], grain: existing?.value?.dictValue))
        if let existing {
            _ = try await client.putRecord(collection: grain, rkey: "self", record: record, swapRecord: existing.cid, auth: auth)
            return .put
        }
        _ = try await client.createRecord(collection: grain, repo: auth.did, rkey: "self", record: record, auth: auth)
        return .made
    }

    /// The grain profile `bsky`, a Bluesky profile, makes: its name,
    /// description and avatar where it has them, made when `grain`, the
    /// grain profile there is, says it was, or now.
    static func fromBluesky(_ bsky: [String: AnyCodable], grain: [String: AnyCodable]?, now: String = DateFormatting.nowISO()) -> [String: AnyCodable] {
        let created = grain?["createdAt"]?.stringValue ?? now
        var record: [String: AnyCodable] = ["$type": AnyCodable(Self.grain), "createdAt": AnyCodable(created)]
        for key in ["displayName", "description"] {
            if let text = bsky[key]?.stringValue, !text.isEmpty {
                record[key] = AnyCodable(text)
            }
        }
        if let avatar = bsky["avatar"], avatar.dictValue != nil {
            record["avatar"] = avatar
        }
        return record
    }

    /// The account's `collection/self`, or nil if its PDS has none.
    private static func read(_ collection: String, client: XRPCClient, auth: AuthContext) async throws -> GetRecordResponse? {
        do {
            return try await client.getRecord(uri: "at://\(auth.did)/\(collection)/self", auth: auth)
        } catch let XRPCError.httpError(_, body) where isNotFound(body) {
            return nil
        }
    }

    /// Whether an answer says there is no such record: atproto's
    /// `RecordNotFound`, which a PDS answers with a 400.
    static func isNotFound(_ body: Data?) -> Bool {
        struct Refusal: Decodable { let error: String? }
        return body.flatMap { try? JSONDecoder().decode(Refusal.self, from: $0) }?.error == "RecordNotFound"
    }
}