summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home/HomeViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:17:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:17:38 -0500
commit6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Home/HomeViewModel.swift
parent1e3c748119c6e9eec27f02146f17ea0302ff648a (diff)
downloadhutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz
hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2
hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch/Views/Home/HomeViewModel.swift')
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift184
1 files changed, 184 insertions, 0 deletions
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index ce27bb0..31dec1b 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -33,6 +33,36 @@ private struct HomeTrackerTicketsPage: Decodable, Sendable {
let results: [HomeTicketPayload]
}
+private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
+ let subscriptions: HomeInboxSubscriptionPage
+}
+
+private struct HomeInboxSubscriptionPage: Decodable, Sendable {
+ let results: [HomeInboxSubscription]
+ let cursor: String?
+}
+
+private struct HomeInboxSubscription: Decodable, Sendable {
+ let list: InboxMailingListReference?
+}
+
+private struct HomeInboxListThreadsResponse: Decodable, Sendable {
+ let list: HomeInboxMailingListThreads
+}
+
+private struct HomeInboxMailingListThreads: Decodable, Sendable {
+ let threads: HomeInboxThreadPage
+}
+
+private struct HomeInboxThreadPage: Decodable, Sendable {
+ let results: [HomeInboxThreadPayload]
+}
+
+private struct HomeInboxThreadPayload: Decodable, Sendable {
+ let updated: Date
+ let subject: String
+}
+
private struct HomeTicketPayload: Decodable, Sendable {
let id: Int
let title: String
@@ -106,9 +136,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
@Observable
@MainActor
final class HomeViewModel {
+ private(set) var projects: [Project] = []
private(set) var failedBuilds: [HomeBuildItem] = []
private(set) var assignedTickets: [HomeAssignedTicket] = []
private(set) var recentBuilds: [HomeBuildItem] = []
+ private(set) var hasUnreadInboxThreads = false
+ private(set) var isLoadingProjects = false
private(set) var isLoadingFailedBuilds = false
private(set) var isLoadingAssignedTickets = false
private(set) var isLoadingRecentBuilds = false
@@ -118,7 +151,10 @@ final class HomeViewModel {
private let currentUser: User
private let client: SRHTClient
+ private let projectService: ProjectService
private let ticketFetchConcurrencyLimit = 6
+ private let inboxUnreadConcurrencyLimit = 4
+ private let inboxUnreadThreadPreviewLimit = 10
private static let jobsQuery = """
query jobs {
@@ -177,12 +213,45 @@ final class HomeViewModel {
}
"""
+ private static let inboxSubscriptionsQuery = """
+ query inboxSubscriptions($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription {
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let inboxListThreadsQuery = """
+ query inboxListThreads($rid: ID!) {
+ list(rid: $rid) {
+ threads {
+ results {
+ updated
+ subject
+ }
+ }
+ }
+ }
+ """
+
init(currentUser: User, client: SRHTClient) {
self.currentUser = currentUser
self.client = client
+ self.projectService = ProjectService(client: client)
}
func loadDashboard() async {
+ isLoadingProjects = true
isLoadingFailedBuilds = true
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
@@ -190,8 +259,19 @@ final class HomeViewModel {
assignedTicketsError = nil
recentBuildsError = nil
+ async let projectsTask = loadProjects()
async let jobsTask = loadRecentJobs()
async let assignedTicketsTask = loadAssignedTickets()
+ async let inboxUnreadTask = loadInboxUnreadState()
+
+ let projectsResult = await projectsTask
+ switch projectsResult {
+ case .success(let projects):
+ self.projects = projects
+ case .failure:
+ self.projects = []
+ }
+ isLoadingProjects = false
let recentJobsResult = await jobsTask
@@ -222,6 +302,16 @@ final class HomeViewModel {
self.assignedTicketsError = error.localizedDescription
}
isLoadingAssignedTickets = false
+
+ hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
+ }
+
+ private func loadProjects() async -> Result<[Project], Error> {
+ do {
+ return .success(try await projectService.fetchProjects())
+ } catch {
+ return .failure(error)
+ }
}
private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
@@ -237,6 +327,100 @@ final class HomeViewModel {
}
}
+ private func loadInboxUnreadState() async -> Bool? {
+ do {
+ return try await fetchHasUnreadInboxThreads()
+ } catch {
+ return nil
+ }
+ }
+
+ private func fetchHasUnreadInboxThreads() async throws -> Bool {
+ let mailingLists = try await fetchInboxMailingLists()
+ guard !mailingLists.isEmpty else { return false }
+
+ var startIndex = mailingLists.startIndex
+ while startIndex < mailingLists.endIndex {
+ let endIndex = mailingLists.index(
+ startIndex,
+ offsetBy: inboxUnreadConcurrencyLimit,
+ limitedBy: mailingLists.endIndex
+ ) ?? mailingLists.endIndex
+ let batch = Array(mailingLists[startIndex..<endIndex])
+
+ let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
+ for mailingList in batch {
+ group.addTask {
+ (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
+ }
+ }
+
+ for await hasUnread in group {
+ if hasUnread {
+ group.cancelAll()
+ return true
+ }
+ }
+ return false
+ }
+
+ if batchHasUnread {
+ return true
+ }
+
+ startIndex = endIndex
+ }
+
+ return false
+ }
+
+ private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
+ var subscriptions: [HomeInboxSubscription] = []
+ var cursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxSubscriptionsQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: HomeInboxSubscriptionsResponse.self
+ )
+
+ subscriptions.append(contentsOf: response.subscriptions.results)
+ guard let nextCursor = response.subscriptions.cursor else {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ var seen = Set<String>()
+ return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
+ }
+
+ private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.inboxListThreadsQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: HomeInboxListThreadsResponse.self
+ )
+
+ return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
+ let normalizedSubject = thread.subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
+ .lowercased()
+ let threadID = "\(mailingList.rid)#\(normalizedSubject)"
+ return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ }
+ }
+
private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
do {
let trackers = try await fetchAllTrackers()