gitbay/Discovery/ProfileViewModel.swift

v1.2.0
gitbay-ios/gitbay/Discovery/ProfileViewModel.swift history · blame · raw

159 lines · 5897 bytes

  1import Foundation
  2import Observation
  3
  4/// `profile show <name>` — the whole profile in one read: description,
  5/// links, org membership (or an org's members), the repositories the
  6/// account may see, and a year of activity.
  7@Observable
  8@MainActor
  9final class ProfileViewModel {
 10
 11    nonisolated struct Profile: Decodable, Sendable, Hashable {
 12        let name: String
 13        let kind: String // user | org
 14        let description: String?
 15        let website: String?
 16        /// Long-form profile text, rendered between the header and the
 17        /// activity graph the way the web renders it.
 18        let about: String?
 19        let aboutFormat: String?
 20        let links: [Link]?
 21        let orgs: [Member]?
 22        let members: [Member]?
 23        let repos: [Repo]
 24        let activity: [Day]?
 25        let activityTotal: Int
 26
 27        enum CodingKeys: String, CodingKey {
 28            case name, kind, description, website, about, links, orgs, members, repos, activity
 29            case aboutFormat = "about_format"
 30            case activityTotal = "activity_total"
 31        }
 32
 33        var isOrg: Bool { kind == "org" }
 34
 35        /// Who may write this profile: a user edits their own, and an org
 36        /// is edited by its admins — the rule `org profile` enforces.
 37        func canEdit(by username: String?) -> Bool {
 38            guard let username else { return false }
 39            if isOrg {
 40                return (members ?? []).contains { $0.name == username && $0.role == "admin" }
 41            }
 42            return name == username
 43        }
 44
 45        /// The filename the about text is rendered under. The server has
 46        /// none to dispatch on either, so the stored format picks the
 47        /// extension and anything but org is markdown.
 48        var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
 49
 50        /// One of the free-form links a profile carries beside its
 51        /// website. The server caps the list at five.
 52        nonisolated struct Link: Decodable, Sendable, Hashable {
 53            let label: String?
 54            let url: String
 55
 56            /// Labels are optional; a link without one shows its URL.
 57            var title: String {
 58                if let label, !label.isEmpty { return label }
 59                return url
 60            }
 61        }
 62
 63        nonisolated struct Member: Decodable, Sendable, Hashable, Identifiable {
 64            let name: String
 65            let role: String?
 66            var id: String { name }
 67        }
 68
 69        nonisolated struct Repo: Decodable, Sendable, Hashable, Identifiable {
 70            let path: String
 71            let visibility: String
 72            let description: String?
 73            let archived: Bool?
 74            let topics: [String]?
 75            let license: String?
 76            let defaultBranch: String?
 77            let updated: String?
 78
 79            enum CodingKeys: String, CodingKey {
 80                case path, visibility, description, archived, topics, license, updated
 81                case defaultBranch = "default_branch"
 82            }
 83
 84            var id: String { path }
 85            var isPrivate: Bool { visibility == "private" }
 86            var isArchived: Bool { archived ?? false }
 87            var name: String { String(path.split(separator: "/").last ?? "") }
 88
 89            /// "main · MIT · updated 2026-08-30", the line the web's repo
 90            /// rows carry. Parts the server did not report are left out.
 91            var meta: String {
 92                var parts: [String] = []
 93                if let defaultBranch, !defaultBranch.isEmpty { parts.append(defaultBranch) }
 94                if let license, !license.isEmpty { parts.append(license) }
 95                if let updated, !updated.isEmpty { parts.append("updated \(updated)") }
 96                return parts.joined(separator: " · ")
 97            }
 98        }
 99
100        /// One day's contributions. Days with none are omitted, so the
101        /// calendar is filled in by the view.
102        nonisolated struct Day: Decodable, Sendable, Hashable {
103            let date: String
104            let count: Int
105        }
106    }
107
108    private(set) var state: LoadState<Profile> = .loading
109    private(set) var actionError: String?
110    private(set) var working = false
111
112    private let client: GitbayClient
113    let name: String
114
115    init(client: GitbayClient, name: String) {
116        self.client = client
117        self.name = name
118    }
119
120    func load() async {
121        do {
122            state = .loaded(try await client.read(["profile", "show", name], as: Profile.self))
123        } catch {
124            state = .from(error)
125        }
126    }
127
128    /// `org create <name>`.
129    func createOrg(_ name: String) async -> Bool {
130        let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
131        guard !name.isEmpty else { return false }
132        await perform(["org", "create", name])
133        return actionError == nil
134    }
135
136    /// `profile set` for the caller's own profile, `org profile <org>` for
137    /// an organization; both take the same flags. The about text travels
138    /// over stdin, never argv, since it is long-form and may carry newlines.
139    func saveProfile(_ edit: ProfileEdit) async -> Bool {
140        guard edit.validationError == nil else { return false }
141        let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
142        await perform(command + edit.flags(), stdin: edit.aboutStdin)
143        return actionError == nil
144    }
145
146    private func perform(_ argv: [String], stdin: String? = nil) async {
147        working = true
148        actionError = nil
149        defer { working = false }
150        do {
151            try await client.run(argv, stdin: stdin)
152            await load()
153        } catch let error as GitbayError {
154            actionError = error.userFacingMessage
155        } catch {
156            actionError = GitbayError.transport(error).userFacingMessage
157        }
158    }
159}