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 | |
| 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
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 176 | ||||
| -rw-r--r-- | Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata | 8 | ||||
| -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 | ||||
| -rw-r--r-- | HutchWidgetExtension/HutchWidgetExtension.entitlements | 10 | ||||
| -rw-r--r-- | HutchWidgetExtension/Info.plist | 29 | ||||
| -rw-r--r-- | HutchWidgetExtension/NeedsAttentionWidget.swift | 226 | ||||
| -rw-r--r-- | Shared/NeedsAttentionSnapshot.swift | 109 |
13 files changed, 687 insertions, 39 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index be22f3f..ee8e2d6 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; + 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -20,6 +21,13 @@ remoteGlobalIDString = 8B4B28D02F6704280045FA19; remoteInfo = Hutch; }; + 8BE082022F80000100000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8BE081F72F80000100000001; + remoteInfo = HutchWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -30,6 +38,7 @@ 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; + 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -40,6 +49,13 @@ ); target = 8B4B28D02F6704280045FA19 /* Hutch */; }; + 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -56,8 +72,35 @@ path = HutchTests; sourceTree = "<group>"; }; + 8BE081F42F80000100000001 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = "<group>"; + }; + 8BE081F52F80000100000001 /* HutchWidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */, + ); + path = HutchWidgetExtension; + sourceTree = "<group>"; + }; /* End PBXFileSystemSynchronizedRootGroup section */ +/* Begin PBXCopyFilesBuildPhase section */ + 8BE081FA2F80000100000001 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFrameworksBuildPhase section */ 8B4B28CE2F6704280045FA19 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -73,6 +116,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE081F92F80000100000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -85,6 +135,8 @@ 8B2F89642F69DEB900FC0253 /* README.md */, 8B2F89652F69DEB900FC0253 /* SECURITY.md */, 8B4B28D32F6704280045FA19 /* Hutch */, + 8BE081F42F80000100000001 /* Shared */, + 8BE081F52F80000100000001 /* HutchWidgetExtension */, 8B8182B82F6B73F3000AE049 /* HutchTests */, 8B4B28D22F6704280045FA19 /* Products */, ); @@ -95,6 +147,7 @@ children = ( 8B4B28D12F6704280045FA19 /* Hutch.app */, 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, + 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */, ); name = Products; sourceTree = "<group>"; @@ -109,13 +162,16 @@ 8B4B28CD2F6704280045FA19 /* Sources */, 8B4B28CE2F6704280045FA19 /* Frameworks */, 8B4B28CF2F6704280045FA19 /* Resources */, + 8BE081FA2F80000100000001 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 8BE082042F80000100000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 8B4B28D32F6704280045FA19 /* Hutch */, + 8BE081F42F80000100000001 /* Shared */, ); name = Hutch; packageProductDependencies = ( @@ -147,6 +203,29 @@ productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 8BE081F72F80000100000001 /* HutchWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */; + buildPhases = ( + 8BE081F82F80000100000001 /* Sources */, + 8BE081F92F80000100000001 /* Frameworks */, + 8BE081FB2F80000100000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8BE081F42F80000100000001 /* Shared */, + 8BE081F52F80000100000001 /* HutchWidgetExtension */, + ); + name = HutchWidgetExtension; + packageProductDependencies = ( + ); + productName = HutchWidgetExtension; + productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -182,6 +261,7 @@ targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, 8B8182B62F6B73F3000AE049 /* HutchTests */, + 8BE081F72F80000100000001 /* HutchWidgetExtension */, ); }; /* End PBXProject section */ @@ -204,6 +284,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE081FB2F80000100000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -221,6 +308,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BE081F82F80000100000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -229,6 +323,11 @@ target = 8B4B28D02F6704280045FA19 /* Hutch */; targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; }; + 8BE082042F80000100000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BE081F72F80000100000001 /* HutchWidgetExtension */; + targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -358,8 +457,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -376,7 +476,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -394,8 +494,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -412,7 +513,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.1; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -469,6 +570,64 @@ }; name = Release; }; + 8BE081FC2F80000100000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HutchWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.4.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8BE081FD2F80000100000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HutchWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 2.4.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -499,6 +658,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BE081FC2F80000100000001 /* Debug */, + 8BE081FD2F80000100000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B4B28C92F6704280045FA19 /* Project object */; diff --git a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 919434a..24c0c84 100644 --- a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,12 @@ <FileRef location = "self:"> </FileRef> + <Group + location = "group:Shared" + name = "Shared"> + </Group> + <Group + location = "group:HutchWidgetExtension" + name = "HutchWidgetExtension"> + </Group> </Workspace> 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 { diff --git a/HutchWidgetExtension/HutchWidgetExtension.entitlements b/HutchWidgetExtension/HutchWidgetExtension.entitlements new file mode 100644 index 0000000..9ed683a --- /dev/null +++ b/HutchWidgetExtension/HutchWidgetExtension.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/HutchWidgetExtension/Info.plist b/HutchWidgetExtension/Info.plist new file mode 100644 index 0000000..7b41344 --- /dev/null +++ b/HutchWidgetExtension/Info.plist @@ -0,0 +1,29 @@ +<?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>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Needs Attention</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.widgetkit-extension</string> + </dict> +</dict> +</plist> diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift new file mode 100644 index 0000000..fff030f --- /dev/null +++ b/HutchWidgetExtension/NeedsAttentionWidget.swift @@ -0,0 +1,226 @@ +import SwiftUI +import WidgetKit + +struct NeedsAttentionEntry: TimelineEntry { + let date: Date + let snapshot: NeedsAttentionSnapshot? +} + +struct NeedsAttentionTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> NeedsAttentionEntry { + NeedsAttentionEntry( + date: .now, + snapshot: NeedsAttentionSnapshot( + unreadInboxThreads: 3, + assignedOpenTickets: 2, + failedBuilds: 1, + updatedAt: .now + ) + ) + } + + func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) { + completion( + NeedsAttentionEntry( + date: .now, + snapshot: NeedsAttentionSnapshotStore.load() + ) + ) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) { + let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200) + let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .after(refreshDate))) + } +} + +struct NeedsAttentionWidget: Widget { + static let kind = NeedsAttentionWidgetConfiguration.kind + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in + NeedsAttentionWidgetView(entry: entry) + } + .configurationDisplayName("Needs Attention") + .description("Unread inbox threads, assigned tickets, and failed builds.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct NeedsAttentionWidgetView: View { + let entry: NeedsAttentionEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + Group { + if let snapshot = entry.snapshot, snapshot.hasAnyData { + if snapshot.allCountsAreZero { + clearState + } else { + switch family { + case .systemMedium: + mediumView(snapshot: snapshot) + default: + smallView(snapshot: snapshot) + } + } + } else { + fallbackState + } + } + .widgetURL(URL(string: "hutch://home")) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 1) { + Text("Hutch") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("Needs Attention") + .font(.headline) + .lineLimit(1) + } + } + + private func smallView(snapshot: NeedsAttentionSnapshot) -> some View { + VStack(alignment: .leading, spacing: 12) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 8) { + compactMetric( + count: snapshot.unreadInboxThreads, + label: "Unread", + tint: unreadTint(for: snapshot.unreadInboxThreads) + ) + compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned") + compactMetric( + count: snapshot.failedBuilds, + label: "Failed", + tint: failedTint(for: snapshot.failedBuilds) + ) + } + Spacer(minLength: 0) + } + .padding() + } + + private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { + VStack(alignment: .leading, spacing: 18) { + header + .padding(.top, 6) + HStack(alignment: .top, spacing: 12) { + metricColumn( + count: snapshot.unreadInboxThreads, + label: "Unread", + systemImage: "tray", + tint: unreadTint(for: snapshot.unreadInboxThreads) + ) + metricColumn( + count: snapshot.assignedOpenTickets, + label: "Assigned", + systemImage: "ticket" + ) + metricColumn( + count: snapshot.failedBuilds, + label: "Failed", + systemImage: "hammer", + tint: failedTint(for: snapshot.failedBuilds) + ) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 16) + } + + private var clearState: some View { + VStack(alignment: .leading, spacing: 12) { + header + Spacer(minLength: 0) + Text("All clear") + .font(.title3.weight(.semibold)) + Text("No unread threads, assigned tickets, or failed builds.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } + + private var fallbackState: some View { + VStack(alignment: .leading, spacing: 12) { + header + Spacer(minLength: 0) + Text("Open Hutch") + .font(.title3.weight(.semibold)) + Text("Unable to load recent counts.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + + private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View { + HStack(alignment: .center, spacing: 6) { + Text(displayCount(count)) + .font(.title2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(tint) + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + } + + private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(tint) + .frame(width: 24, alignment: .leading) + .padding(.trailing, 4) + Text(displayCount(count)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundStyle(tint) + } + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(height: 16, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func displayCount(_ count: Int?) -> String { + guard let count else { return "—" } + return "\(count)" + } + + private func unreadTint(for count: Int?) -> Color { + guard let count, count > 0 else { return .primary } + return .blue + } + + private func failedTint(for count: Int?) -> Color { + guard let count else { return .primary } + return count > 0 ? .red : .green + } +} + +@main +struct HutchWidgets: WidgetBundle { + var body: some Widget { + NeedsAttentionWidget() + } +} diff --git a/Shared/NeedsAttentionSnapshot.swift b/Shared/NeedsAttentionSnapshot.swift new file mode 100644 index 0000000..f386af6 --- /dev/null +++ b/Shared/NeedsAttentionSnapshot.swift @@ -0,0 +1,109 @@ +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +enum HutchAppGroup { + static let identifier = "group.net.cleberg.Hutch" +} + +enum NeedsAttentionWidgetConfiguration { + static let kind = "NeedsAttentionWidget" +} + +struct NeedsAttentionSnapshot: Codable, Sendable { + let unreadInboxThreads: Int? + let assignedOpenTickets: Int? + let failedBuilds: Int? + let updatedAt: Date + + static let unavailable = NeedsAttentionSnapshot( + unreadInboxThreads: nil, + assignedOpenTickets: nil, + failedBuilds: nil, + updatedAt: .now + ) + + var hasAnyData: Bool { + unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil + } + + var allCountsAreZero: Bool { + let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 } + return !counts.isEmpty && counts.allSatisfy { $0 == 0 } + } +} + +enum NeedsAttentionSnapshotStore { + private static let snapshotKey = "needsAttention.snapshot" + + static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? { + guard let defaults, + let data = defaults.data(forKey: snapshotKey) else { + return nil + } + + return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data) + } + + static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) { + guard let defaults, + let data = try? JSONEncoder().encode(snapshot) else { + return + } + + defaults.set(data, forKey: snapshotKey) + reloadWidgetTimelines() + } + + static func update( + unreadInboxThreads: Int? = nil, + assignedOpenTickets: Int? = nil, + failedBuilds: Int? = nil, + defaults: UserDefaults? = sharedDefaults() + ) { + let existing = load(defaults: defaults) + let snapshot = NeedsAttentionSnapshot( + unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads, + assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets, + failedBuilds: failedBuilds ?? existing?.failedBuilds, + updatedAt: .now + ) + save(snapshot, defaults: defaults) + } + + static func adjustUnreadInboxThreads( + by delta: Int, + defaults: UserDefaults? = sharedDefaults() + ) { + guard let existing = load(defaults: defaults), + let unreadInboxThreads = existing.unreadInboxThreads else { + return + } + + save( + NeedsAttentionSnapshot( + unreadInboxThreads: max(0, unreadInboxThreads + delta), + assignedOpenTickets: existing.assignedOpenTickets, + failedBuilds: existing.failedBuilds, + updatedAt: .now + ), + defaults: defaults + ) + } + + static func clear(defaults: UserDefaults? = sharedDefaults()) { + defaults?.removeObject(forKey: snapshotKey) + reloadWidgetTimelines() + } + + private static func sharedDefaults() -> UserDefaults? { + UserDefaults(suiteName: HutchAppGroup.identifier) + } + + private static func reloadWidgetTimelines() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind) + #endif + } +} |
