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

grain.social/grain-ios

1 collaborator
0
1
main
325 lines · 11.0 KB Raw History
import Foundation

// Response types for feed-related XRPC queries.

struct GetFeedResponse: Codable, Sendable {
    var items: [GrainGallery]?
    var cursor: String?
}

struct GetGalleryResponse: Codable, Sendable {
    let gallery: GrainGallery
}

struct GetCommentThreadResponse: Codable, Sendable {
    let comments: [GrainComment]
    var cursor: String?
    var totalCount: Int?
}

struct GetPreferencesResponse: Codable, Sendable {
    let preferences: UserPreferences
}

struct PutPreferencesInput: Encodable, Sendable {
    let preferences: UserPreferences
}

struct NotifPref: Codable, Sendable, Equatable {
    var push: Bool
    var inApp: Bool
    var from: String // "all" or "follows"

    static let `default` = NotifPref(push: true, inApp: true, from: "all")
}

struct NotificationPrefs: Codable, Sendable, Equatable {
    var favorites: NotifPref?
    var follows: NotifPref?
    var comments: NotifPref?
    var mentions: NotifPref?

    static let `default` = NotificationPrefs(
        favorites: .default,
        follows: .default,
        comments: .default,
        mentions: .default
    )
}

/// The viewer's grain preferences, `social.grain.actor.defs#preferences`:
/// on the wire an array holding each kind at most once, by its `$type`.
/// `theme` is the web's; the app keeps it so writing the set back leaves it
/// as it was.
struct UserPreferences: Codable, Sendable, Equatable {
    var pinnedFeeds: [PinnedFeed]?
    var includeExif: Bool?
    var includeLocation: Bool?
    var notificationPrefs: NotificationPrefs?
    var theme: String?

    static let pinnedFeedsType = "social.grain.actor.defs#pinnedFeedsPref"
    static let photoMetadataType = "social.grain.actor.defs#photoMetadataPref"
    static let notificationsType = "social.grain.actor.defs#notificationsPref"
    static let themeType = "social.grain.actor.defs#themePref"

    init(
        pinnedFeeds: [PinnedFeed]? = nil,
        includeExif: Bool? = nil,
        includeLocation: Bool? = nil,
        notificationPrefs: NotificationPrefs? = nil,
        theme: String? = nil
    ) {
        self.pinnedFeeds = pinnedFeeds
        self.includeExif = includeExif
        self.includeLocation = includeLocation
        self.notificationPrefs = notificationPrefs
        self.theme = theme
    }

    private enum Keys: String, CodingKey {
        case type = "$type"
        case items, includeExif, includeLocation, theme
    }

    /// One item of the array, whatever its kind, with the fields each kind
    /// has. A kind the app does not know decodes to nothing.
    private struct Item: Decodable {
        let type: String?
        let items: [PinnedFeed]?
        let includeExif: Bool?
        let includeLocation: Bool?
        let theme: String?
        let favorites: NotifPref?
        let follows: NotifPref?
        let comments: NotifPref?
        let mentions: NotifPref?

        private enum CodingKeys: String, CodingKey {
            case type = "$type"
            case items, includeExif, includeLocation, theme, favorites, follows, comments, mentions
        }
    }

    init(from decoder: Decoder) throws {
        self.init()
        for item in try [Item](from: decoder) {
            switch item.type {
            case Self.pinnedFeedsType:
                pinnedFeeds = item.items
            case Self.photoMetadataType:
                includeExif = item.includeExif
                includeLocation = item.includeLocation
            case Self.notificationsType:
                notificationPrefs = NotificationPrefs(
                    favorites: item.favorites,
                    follows: item.follows,
                    comments: item.comments,
                    mentions: item.mentions
                )
            case Self.themeType:
                theme = item.theme
            default:
                break
            }
        }
    }

    func encode(to encoder: Encoder) throws {
        var list = encoder.unkeyedContainer()
        if let pinnedFeeds {
            var item = list.nestedContainer(keyedBy: Keys.self)
            try item.encode(Self.pinnedFeedsType, forKey: .type)
            try item.encode(pinnedFeeds, forKey: .items)
        }
        if includeExif != nil || includeLocation != nil {
            var item = list.nestedContainer(keyedBy: Keys.self)
            try item.encode(Self.photoMetadataType, forKey: .type)
            try item.encodeIfPresent(includeExif, forKey: .includeExif)
            try item.encodeIfPresent(includeLocation, forKey: .includeLocation)
        }
        if let theme {
            var item = list.nestedContainer(keyedBy: Keys.self)
            try item.encode(Self.themeType, forKey: .type)
            try item.encode(theme, forKey: .theme)
        }
        if let notificationPrefs {
            try list.encode(TypedNotificationPrefs(prefs: notificationPrefs))
        }
    }
}

/// A `notificationsPref`: its kinds beside its `$type`.
private struct TypedNotificationPrefs: Encodable {
    let prefs: NotificationPrefs

    private enum Keys: String, CodingKey {
        case type = "$type"
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: Keys.self)
        try container.encode(UserPreferences.notificationsType, forKey: .type)
        try prefs.encode(to: encoder)
    }
}

struct PinnedFeed: Codable, Sendable, Identifiable, Hashable {
    let id: String
    let label: String
    let type: String
    let path: String

    static let defaults: [PinnedFeed] = [
        PinnedFeed(id: "recent", label: "Recent", type: "feed", path: "/"),
        PinnedFeed(id: "following", label: "Following", type: "feed", path: "/feeds/following"),
        PinnedFeed(id: "foryou", label: "For you", type: "feed", path: "/feeds/for-you"),
    ]

    /// The name of the feed it opens (e.g. "recent", "following", "camera", "location", "hashtag")
    var feedName: String {
        switch type {
        case "camera": "camera"
        case "location": "location"
        case "hashtag": "hashtag"
        default: id
        }
    }

    /// Extract the value part from the id (e.g. "Sony A7III" from "camera:Sony A7III")
    var feedValue: String? {
        guard id.contains(":") else { return nil }
        return String(id.split(separator: ":", maxSplits: 1).last ?? "")
    }
}

struct SearchGalleriesResponse: Codable, Sendable {
    var items: [GrainGallery]?
    var cursor: String?
}

// MARK: - Convenience Extensions

extension XRPCClient {
    func getFeed(
        feed: String,
        limit: Int = 30,
        cursor: String? = nil,
        actor: String? = nil,
        camera: String? = nil,
        location: String? = nil,
        // A place's display name, alongside `location`. The index folds
        // several cells into one place and counts across all of them; the
        // feed asked for one cell shows only that cell's galleries, so the
        // name is what makes the feed match the count. The appview unions
        // every cell sharing it.
        name: String? = nil,
        tag: String? = nil,
        auth: AuthContext? = nil
    ) async throws -> GetFeedResponse {
        var params = ["limit": String(limit)]
        if let cursor {
            params["cursor"] = cursor
        }
        if let actor {
            params["actor"] = actor
        }
        if let camera {
            params["camera"] = camera
        }
        if let location {
            params["location"] = location
        }
        if let name {
            params["name"] = name
        }
        if let tag {
            params["tag"] = tag
        }
        params["feed"] = Self.feedURI(feed)
        return try await query("social.grain.feed.getFeed", params: params, auth: auth, as: GetFeedResponse.self)
    }

    /// The URI grain-serve names its feed `name` by, as
    /// `social.grain.feed.describeFeeds` lists it.
    static func feedURI(_ name: String) -> String {
        "at://\(AuthManager.appviewDID)/social.grain.feed.generator/\(name)"
    }

    func getPreferences(auth: AuthContext? = nil) async throws -> GetPreferencesResponse {
        try await query("social.grain.actor.getPreferences", auth: auth, as: GetPreferencesResponse.self)
    }

    /// Replace the viewer's whole set of preferences.
    func putPreferences(_ preferences: UserPreferences, auth: AuthContext? = nil) async throws {
        try await procedure("social.grain.actor.putPreferences", input: PutPreferencesInput(preferences: preferences), auth: auth)
    }

    /// Change one kind of preference: the set is read, changed and written
    /// back whole, so the kinds left alone stay as they were.
    func updatePreferences(auth: AuthContext? = nil, _ change: (inout UserPreferences) -> Void) async throws {
        var preferences = try await getPreferences(auth: auth).preferences
        change(&preferences)
        try await putPreferences(preferences, auth: auth)
    }

    func getGallery(uri: String, auth: AuthContext? = nil) async throws -> GetGalleryResponse {
        try await query("social.grain.gallery.getGallery", params: ["gallery": uri], auth: auth, as: GetGalleryResponse.self)
    }

    func getCommentThread(
        subject: String,
        limit: Int = 20,
        cursor: String? = nil,
        auth: AuthContext? = nil
    ) async throws -> GetCommentThreadResponse {
        var params = ["subject": subject, "limit": String(limit)]
        if let cursor {
            params["cursor"] = cursor
        }
        return try await query("social.grain.comment.getThread", params: params, auth: auth, as: GetCommentThreadResponse.self)
    }

    func putPinnedFeeds(_ feeds: [PinnedFeed], auth: AuthContext? = nil) async throws {
        try await updatePreferences(auth: auth) { $0.pinnedFeeds = feeds }
    }

    func putIncludeExif(_ value: Bool, auth: AuthContext? = nil) async throws {
        try await updatePreferences(auth: auth) { $0.includeExif = value }
    }

    func putIncludeLocation(_ value: Bool, auth: AuthContext? = nil) async throws {
        try await updatePreferences(auth: auth) { $0.includeLocation = value }
    }

    func putNotificationPrefs(_ prefs: NotificationPrefs, auth: AuthContext? = nil) async throws {
        try await updatePreferences(auth: auth) { $0.notificationPrefs = prefs }
    }

    func getActorFavorites(
        actor: String,
        limit: Int = 30,
        cursor: String? = nil,
        auth: AuthContext? = nil
    ) async throws -> GetFeedResponse {
        var params = ["actor": actor, "limit": String(limit)]
        if let cursor {
            params["cursor"] = cursor
        }
        return try await query("social.grain.actor.getFavorites", params: params, auth: auth, as: GetFeedResponse.self)
    }

    func searchGalleries(
        query queryString: String,
        limit: Int = 30,
        cursor: String? = nil,
        fuzzy: Bool = true,
        auth: AuthContext? = nil
    ) async throws -> SearchGalleriesResponse {
        var params = ["q": queryString, "limit": String(limit), "fuzzy": String(fuzzy)]
        if let cursor {
            params["cursor"] = cursor
        }
        return try await query("social.grain.gallery.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self)
    }
}