krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.9.0: Hutch/Views/Lookup/UserProfileViewModel.swift · raw

  1import Foundation
  2
  3@Observable
  4@MainActor
  5final class UserProfileViewModel {
  6    private(set) var repositories: [RepositorySummary] = []
  7    private(set) var trackers: [TrackerSummary] = []
  8    private(set) var contributionCalendar: ContributionCalendarResponse?
  9    private(set) var contributionStats: ContributionStatsResponse?
 10    private(set) var isLoadingRepositories = false
 11    private(set) var isLoadingTrackers = false
 12    private(set) var isLoadingContributions = false
 13    var repositoriesError: String?
 14    var trackersError: String?
 15    var contributionsError: String?
 16
 17    private let client: SRHTClient
 18    private let statsService: HutchStatsService
 19    let ownerUsername: String
 20    let actor: String
 21
 22    var isContributionActivityIndexedButEmpty: Bool {
 23        contributionDisplayState != .populated && contributionDisplayState != .unavailable
 24    }
 25
 26    var contributionStatusText: String? {
 27        switch contributionDisplayState {
 28        case .indexing:
 29            return "Activity is being indexed."
 30        case .empty:
 31            return "No contribution activity found."
 32        case .unavailable:
 33            return "Contribution activity is unavailable."
 34        case .populated:
 35            return nil
 36        }
 37    }
 38
 39    init(ownerUsername: String, actor: String, client: SRHTClient, statsService: HutchStatsService) {
 40        self.ownerUsername = ownerUsername
 41        self.actor = actor
 42        self.client = client
 43        self.statsService = statsService
 44    }
 45
 46    func loadRepositories() async {
 47        isLoadingRepositories = true
 48        repositoriesError = nil
 49        defer { isLoadingRepositories = false }
 50
 51        do {
 52            let cached = try await client.executeCached(
 53                service: .git,
 54                query: Self.repositoriesQuery,
 55                variables: ["owner": ownerUsername],
 56                responseType: UserRepositoriesResponse.self,
 57                cacheKey: APICacheKeys.userRepositories(owner: ownerUsername),
 58                resourceType: .userProfile,
 59                ttl: APICacheTTLs.userProfile,
 60                policy: .cacheFirstThenRefresh
 61            )
 62            repositories = cached.value.user.repositories.results.map { $0.repositorySummary(service: .git) }
 63        } catch {
 64            repositoriesError = error.userFacingMessage
 65        }
 66    }
 67
 68    func updateRepository(_ repository: RepositorySummary) {
 69        guard let index = repositories.firstIndex(where: { $0.id == repository.id }) else { return }
 70        repositories[index] = repository
 71        repositories.sort { lhs, rhs in
 72            if lhs.updated == rhs.updated {
 73                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
 74            }
 75            return lhs.updated > rhs.updated
 76        }
 77    }
 78
 79    func loadTrackers() async {
 80        isLoadingTrackers = true
 81        trackersError = nil
 82        defer { isLoadingTrackers = false }
 83
 84        do {
 85            let cached = try await client.executeCached(
 86                service: .todo,
 87                query: Self.trackersQuery,
 88                variables: ["owner": ownerUsername],
 89                responseType: UserTrackersResponse.self,
 90                cacheKey: APICacheKeys.userTrackers(owner: ownerUsername),
 91                resourceType: .userProfile,
 92                ttl: APICacheTTLs.userProfile,
 93                policy: .cacheFirstThenRefresh
 94            )
 95            trackers = cached.value.user.trackers.results
 96        } catch {
 97            trackersError = error.userFacingMessage
 98        }
 99    }
100
101    func loadContributions(endingOn endDate: Date? = nil) async {
102        isLoadingContributions = true
103        contributionsError = nil
104        defer { isLoadingContributions = false }
105
106        let resolvedEndDate = Calendar.contributionCalendar.startOfDay(for: endDate ?? Date())
107        do {
108            async let contributionCalendar = statsService.fetchContributionCalendar(actor: actor, endingOn: resolvedEndDate)
109            async let contributionStats = statsService.fetchContributionStats(actor: actor, endingOn: resolvedEndDate)
110
111            self.contributionCalendar = try await contributionCalendar
112            self.contributionStats = try await contributionStats
113            if contributionDisplayState != .unavailable {
114                contributionsError = nil
115            }
116        } catch {
117            contributionsError = error.userFacingMessage
118        }
119    }
120
121    private static let repositoriesQuery = """
122    query userRepositories($owner: String!) {
123        user(username: $owner) {
124            repositories {
125                results {
126                    id
127                    rid
128                    name
129                    description
130                    visibility
131                    updated
132                    owner { canonicalName }
133                    HEAD { name target }
134                }
135                cursor
136            }
137        }
138    }
139    """
140
141    private static let trackersQuery = """
142    query userTrackers($owner: String!) {
143        user(username: $owner) {
144            trackers {
145                results {
146                    id
147                    rid
148                    name
149                    description
150                    visibility
151                    updated
152                    owner { canonicalName }
153                }
154                cursor
155            }
156        }
157    }
158    """
159
160    private struct UserRepositoriesResponse: Decodable, Sendable {
161        let user: UserRepositoriesContainer
162    }
163
164    private struct UserRepositoriesContainer: Decodable, Sendable {
165        let repositories: RepositoriesPage
166    }
167
168    private struct RepositoriesPage: Decodable, Sendable {
169        let results: [RepositoryPayload]
170        let cursor: String?
171    }
172
173    private struct RepositoryPayload: Decodable, Sendable {
174        let id: Int
175        let rid: String
176        let name: String
177        let description: String?
178        let visibility: Visibility
179        let updated: Date
180        let owner: Entity
181        let head: Reference?
182
183        enum CodingKeys: String, CodingKey {
184            case id, rid, name, description, visibility, updated, owner
185            case head = "HEAD"
186        }
187
188        func repositorySummary(service: SRHTService) -> RepositorySummary {
189            RepositorySummary(
190                fields: .init(
191                    id: id,
192                    rid: rid,
193                    service: service,
194                    name: name,
195                    description: description,
196                    visibility: visibility,
197                    updated: updated,
198                    owner: owner,
199                    head: head
200                )
201            )
202        }
203    }
204
205    private struct UserTrackersResponse: Decodable, Sendable {
206        let user: UserTrackersContainer
207    }
208
209    private struct UserTrackersContainer: Decodable, Sendable {
210        let trackers: TrackersPage
211    }
212
213    private struct TrackersPage: Decodable, Sendable {
214        let results: [TrackerSummary]
215        let cursor: String?
216    }
217
218    private enum ContributionDisplayState {
219        case populated
220        case indexing
221        case empty
222        case unavailable
223    }
224
225    private var contributionDisplayState: ContributionDisplayState {
226        if let contributionStats, contributionStats.totalEvents > 0 {
227            return .populated
228        }
229
230        if let contributionCalendar, !contributionCalendar.isEmpty {
231            return .populated
232        }
233
234        let indexingState = contributionStats?.indexingState ?? contributionCalendar?.indexingState
235        switch indexingState {
236        case .pending:
237            return .indexing
238        case .error:
239            return .unavailable
240        case .indexed, nil:
241            return .empty
242        }
243    }
244
245}