summaryrefslogtreecommitdiff
path: root/Hutch/Views/Home
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
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')
-rw-r--r--Hutch/Views/Home/HomeView.swift109
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift184
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()