diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 15:17:38 -0500 |
| commit | 6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (patch) | |
| tree | 572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Home | |
| parent | 1e3c748119c6e9eec27f02146f17ea0302ff648a (diff) | |
| download | hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.gz hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.tar.bz2 hutch-6ba4e967d5dfb5d3c7bb97a0f2662f3180595563.zip | |
feat: implement support for projects, lists, and pastes
Diffstat (limited to 'Hutch/Views/Home')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 109 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 184 |
2 files changed, 289 insertions, 4 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 1e98039..55ec41e 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -4,6 +4,7 @@ struct HomeView: View { @Environment(AppState.self) private var appState @State private var viewModel: HomeViewModel? private let previewLimit = 4 + private let projectPreviewLimit = 3 var body: some View { Group { @@ -14,6 +15,15 @@ struct HomeView: View { } } .navigationTitle("Home") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + InboxView() + } label: { + HomeInboxToolbarIcon(hasUnreadThreads: viewModel?.hasUnreadInboxThreads == true) + } + } + } .task { if viewModel == nil, let currentUser = appState.currentUser { let vm = HomeViewModel(currentUser: currentUser, client: appState.client) @@ -26,16 +36,17 @@ struct HomeView: View { @ViewBuilder private func content(_ viewModel: HomeViewModel) -> some View { List { + projectsSection(viewModel) assignedTicketsSection(viewModel) recentBuildsSection(viewModel) } .listStyle(.insetGrouped) .overlay { - if viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && - viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { + if viewModel.isLoadingProjects && viewModel.isLoadingAssignedTickets && viewModel.isLoadingRecentBuilds && + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty { SRHTLoadingStateView(message: "Loading Home…") - } else if !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && - viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && + } else if !viewModel.isLoadingProjects && !viewModel.isLoadingAssignedTickets && !viewModel.isLoadingRecentBuilds && + viewModel.projects.isEmpty && viewModel.assignedTickets.isEmpty && viewModel.recentBuilds.isEmpty && viewModel.assignedTicketsError == nil && viewModel.recentBuildsError == nil { ContentUnavailableView( "All Clear", @@ -50,6 +61,25 @@ struct HomeView: View { } @ViewBuilder + private func projectsSection(_ viewModel: HomeViewModel) -> some View { + if !viewModel.projects.isEmpty { + Section { + ForEach(viewModel.projects.prefix(projectPreviewLimit)) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HomeProjectRow(project: project) + } + } + } header: { + HomeSectionHeader("Projects") { + HomeProjectsListView(viewModel: viewModel) + } + } + } + } + + @ViewBuilder private func assignedTicketsSection(_ viewModel: HomeViewModel) -> some View { Section { if viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { @@ -123,6 +153,77 @@ struct HomeView: View { } +private struct HomeInboxToolbarIcon: View { + let hasUnreadThreads: Bool + + var body: some View { + ZStack(alignment: .topTrailing) { + Image(systemName: hasUnreadThreads ? "tray.fill" : "tray") + + if hasUnreadThreads { + Circle() + .fill(.blue) + .frame(width: 9, height: 9) + .offset(x: 4, y: -2) + } + } + .accessibilityLabel(hasUnreadThreads ? "Inbox, unread messages" : "Inbox") + } +} + +private struct HomeProjectRow: View { + let project: Project + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + if let description = project.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let summary = project.resourceSummary { + Text(summary) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .padding(.vertical, 2) + } +} + +private struct HomeProjectsListView: View { + let viewModel: HomeViewModel + + var body: some View { + List { + ForEach(viewModel.projects) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HomeProjectRow(project: project) + } + } + } + .navigationTitle("Projects") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.loadDashboard() + } + .overlay { + if viewModel.isLoadingProjects && viewModel.projects.isEmpty { + SRHTLoadingStateView(message: "Loading projects…") + } + } + } +} + private struct HomeBuildRow: View { let build: HomeBuildItem 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() |
