diff options
| author | Christian Cleberg <[email protected]> | 2026-03-24 18:28:21 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-24 18:28:21 -0500 |
| commit | 94d0b581bbd2be7b63c07fe7f86d736daa96e1f9 (patch) | |
| tree | e0fb93129ab426c2ebdabd2ea3a84b713f843656 /Hutch | |
| parent | 28c5b36bed9e3a4217415079827f657205a080f7 (diff) | |
| download | hutch-2.4.0.tar.gz hutch-2.4.0.tar.bz2 hutch-2.4.0.zip | |
v2.4.0: widgetsv2.4.0
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppState.swift | 15 | ||||
| -rw-r--r-- | Hutch/App/DeepLink.swift | 4 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Hutch.entitlements | 10 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 127 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxViewModel.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 2 |
7 files changed, 133 insertions, 35 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index e74a58b..9788c85 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -77,11 +77,13 @@ final class AppState { let user = try await fetchMe() currentUser = user authPhase = .authenticated + await refreshNeedsAttentionSnapshot() } catch { try? KeychainHelper.deleteToken() client.setToken(nil) currentUser = nil authPhase = .unauthenticated + NeedsAttentionSnapshotStore.clear() } } @@ -98,6 +100,7 @@ final class AppState { try KeychainHelper.saveToken(token) currentUser = user authPhase = .authenticated + await refreshNeedsAttentionSnapshot() } catch { // Roll back — don't leave an invalid token in the client. client.setToken(nil) @@ -111,6 +114,7 @@ final class AppState { HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } await clearWebData() clearWebContentRenderCaches() + NeedsAttentionSnapshotStore.clear() authPhase = .unauthenticated selectedTab = .home } @@ -125,6 +129,7 @@ final class AppState { HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } await clearWebData() clearWebContentRenderCaches() + NeedsAttentionSnapshotStore.clear() authPhase = .unauthenticated selectedTab = .home @@ -270,6 +275,16 @@ final class AppState { selectedTab = .home } + private func refreshNeedsAttentionSnapshot() async { + guard let currentUser else { + NeedsAttentionSnapshotStore.clear() + return + } + + let viewModel = HomeViewModel(currentUser: currentUser, client: client) + await viewModel.loadDashboard() + } + private func clearWebData() async { await withCheckedContinuation { continuation in let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift index a4b8269..e42bd6b 100644 --- a/Hutch/App/DeepLink.swift +++ b/Hutch/App/DeepLink.swift @@ -2,6 +2,7 @@ import Foundation /// Represents a parsed `hutch://` deep link. enum DeepLink: Equatable { + case home /// hutch://git/<owner>/<repo> case repository(owner: String, repo: String) /// hutch://todo/<owner>/<tracker>/<ticketId> @@ -19,6 +20,9 @@ enum DeepLink: Equatable { let components = url.pathComponents(fromScheme: "hutch") switch components.first { + case "home", nil: + self = .home + case "git" where components.count >= 3: let owner = components[1] let repo = components[2] diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 9c4e71a..8d4eef1 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -161,6 +161,10 @@ struct RootView: View { guard appState.isAuthenticated else { return } switch link { + case .home: + homePath = NavigationPath() + appState.selectedTab = .home + case .repository(let owner, let repo): resolveRepositoryLink(owner: owner, repo: repo) @@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View { thread: thread, onViewed: { InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) }, onMarkRead: { InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) }, onMarkUnread: { InboxReadStateStore.markUnread(for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) } ) } diff --git a/Hutch/Hutch.entitlements b/Hutch/Hutch.entitlements new file mode 100644 index 0000000..9ed683a --- /dev/null +++ b/Hutch/Hutch.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.cleberg.Hutch</string> + </array> +</dict> +</plist> diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 50b1854..8b4e7aa 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -59,6 +59,7 @@ private struct HomeInboxMailingListThreads: Decodable, Sendable { private struct HomeInboxThreadPage: Decodable, Sendable { let results: [HomeInboxThreadPayload] + let cursor: String? } private struct HomeInboxThreadPayload: Decodable, Sendable { @@ -143,6 +144,7 @@ final class HomeViewModel { var assignedTickets: [HomeAssignedTicket] = [] var recentBuilds: [HomeBuildItem] = [] private(set) var hasUnreadInboxThreads = false + private(set) var unreadInboxThreadCount: Int? private(set) var isLoadingProjects = false private(set) var isLoadingAssignedTickets = false private(set) var isLoadingRecentBuilds = false @@ -154,7 +156,6 @@ final class HomeViewModel { private let projectService: ProjectService private let ticketFetchConcurrencyLimit = 6 private let inboxUnreadConcurrencyLimit = 4 - private let inboxUnreadThreadPreviewLimit = 10 private static let jobsQuery = """ query jobs { @@ -232,13 +233,14 @@ final class HomeViewModel { """ private static let inboxListThreadsQuery = """ - query inboxListThreads($rid: ID!) { + query inboxListThreads($rid: ID!, $cursor: Cursor) { list(rid: $rid) { - threads { + threads(cursor: $cursor) { results { updated subject } + cursor } } } @@ -280,7 +282,7 @@ final class HomeViewModel { async let projectsTask = loadProjects() async let jobsTask = loadRecentJobs() async let assignedTicketsTask = loadAssignedTickets() - async let inboxUnreadTask = loadInboxUnreadState() + async let inboxUnreadTask = loadInboxUnreadCount() let projectsResult = await projectsTask switch projectsResult { @@ -316,7 +318,9 @@ final class HomeViewModel { } isLoadingAssignedTickets = false - hasUnreadInboxThreads = (await inboxUnreadTask) ?? false + unreadInboxThreadCount = await inboxUnreadTask + hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 + persistNeedsAttentionSnapshot() } func resolveTicket(_ ticket: HomeAssignedTicket) async { @@ -347,6 +351,7 @@ final class HomeViewModel { responseType: UnassignResponse.self ) assignedTickets.removeAll { $0.id == ticket.id } + persistNeedsAttentionSnapshot() } catch { homeLogger.error("Unassign from me failed: \(error, privacy: .public)") } @@ -380,6 +385,7 @@ final class HomeViewModel { repositoryOwner: build.repositoryOwner ) } + persistNeedsAttentionSnapshot() } catch { homeLogger.error("Cancel build failed: \(error, privacy: .public)") } @@ -406,19 +412,21 @@ final class HomeViewModel { } } - private func loadInboxUnreadState() async -> Bool? { + private func loadInboxUnreadCount() async -> Int? { do { - return try await fetchHasUnreadInboxThreads() + return try await fetchUnreadInboxThreadCount() } catch { return nil } } - private func fetchHasUnreadInboxThreads() async throws -> Bool { + private func fetchUnreadInboxThreadCount() async throws -> Int { let mailingLists = try await fetchInboxMailingLists() - guard !mailingLists.isEmpty else { return false } + guard !mailingLists.isEmpty else { return 0 } var startIndex = mailingLists.startIndex + var unreadCount = 0 + var successfulFetchCount = 0 while startIndex < mailingLists.endIndex { let endIndex = mailingLists.index( startIndex, @@ -427,30 +435,41 @@ final class HomeViewModel { ) ?? mailingLists.endIndex let batch = Array(mailingLists[startIndex..<endIndex]) - let batchHasUnread = await withTaskGroup(of: Bool.self) { group in + let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in for mailingList in batch { group.addTask { - (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false + do { + return .success(try await self.fetchUnreadThreadCount(for: mailingList)) + } catch { + return .failure(error) + } } } - for await hasUnread in group { - if hasUnread { - group.cancelAll() - return true + var counts: [Int] = [] + var errors: [Error] = [] + for await result in group { + switch result { + case .success(let count): + counts.append(count) + case .failure(let error): + errors.append(error) } } - return false + return (counts, errors) } - if batchHasUnread { - return true - } + unreadCount += batchResult.0.reduce(0, +) + successfulFetchCount += batchResult.0.count startIndex = endIndex } - return false + guard successfulFetchCount > 0 else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) + } + + return unreadCount } private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { @@ -481,23 +500,40 @@ final class HomeViewModel { 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 - ) + private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int { + var unreadCount = 0 + var cursor: String? + + while true { + var variables: [String: any Sendable] = ["rid": mailingList.rid] + if let cursor { + variables["cursor"] = cursor + } - 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) + let response = try await client.execute( + service: .lists, + query: Self.inboxListThreadsQuery, + variables: variables, + responseType: HomeInboxListThreadsResponse.self + ) + + unreadCount += response.list.threads.results.filter { 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) + }.count + + guard let nextCursor = response.list.threads.cursor else { + break + } + cursor = nextCursor } + + return unreadCount } private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { @@ -616,11 +652,32 @@ final class HomeViewModel { responseType: StatusEventResponse.self ) assignedTickets.removeAll { $0.id == ticket.id } + persistNeedsAttentionSnapshot() } catch { homeLogger.error("Ticket status update failed: \(error, privacy: .public)") } } + private func persistNeedsAttentionSnapshot() { + NeedsAttentionSnapshotStore.save( + NeedsAttentionSnapshot( + unreadInboxThreads: unreadInboxThreadCount, + assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil, + failedBuilds: recentBuildsError == nil + ? recentBuilds.filter { + switch $0.job.status { + case .failed, .timeout: + true + default: + false + } + }.count + : nil, + updatedAt: .now + ) + ) + } + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { jobs.map { job in let repository = primaryRepositoryReference(in: job.manifest) diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift index 42ce223..1d4cfd3 100644 --- a/Hutch/Views/Inbox/InboxViewModel.swift +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -136,6 +136,7 @@ final class InboxViewModel { } return lhs.lastActivityAt > rhs.lastActivityAt } + NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count) } catch { inboxListLogger.error("Inbox request failed") self.error = "Failed to load inbox" @@ -146,11 +147,13 @@ final class InboxViewModel { let viewedAt = max(Date(), thread.lastActivityAt) InboxReadStateStore.markViewed(viewedAt, for: thread.id) threads.removeAll { $0.id == thread.id } + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) } func markThreadUnread(_ thread: InboxThreadSummary) { InboxReadStateStore.markUnread(for: thread.id) updateThread(thread, isUnread: true) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) } func toggleThreadReadState(_ thread: InboxThreadSummary) { diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index e044efb..39645be 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -92,11 +92,13 @@ final class MailingListDetailViewModel { let viewedAt = max(Date(), thread.lastActivityAt) InboxReadStateStore.markViewed(viewedAt, for: thread.id) updateThread(thread, isUnread: false) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) } func markThreadUnread(_ thread: InboxThreadSummary) { InboxReadStateStore.markUnread(for: thread.id) updateThread(thread, isUnread: true) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) } private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { |
