diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 01:44:26 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 01:44:26 -0500 |
| commit | 1c4e968c31ee451666a65e05c762c9422c768426 (patch) | |
| tree | 7137471dd7cdd5583db4ffbead31414497c431a9 /Hutch/Views | |
| parent | a0ed11a3c4ddb2f532fa70884dc2829a23a5eec8 (diff) | |
| download | hutch-1c4e968c31ee451666a65e05c762c9422c768426.tar.gz hutch-1c4e968c31ee451666a65e05c762c9422c768426.tar.bz2 hutch-1c4e968c31ee451666a65e05c762c9422c768426.zip | |
Take inbox unread count off pull-to-refresh path (#9)
The inbox count paginates every thread of every subscribed list (~25s),
and .refreshable binds the spinner to loadDashboard returning, so pull
held for the whole time. Refresh the count in a cancellable background
task that updates the badge when it lands, so pull returns once the fast
branches settle. The widget / App Intents snapshot refresh, which builds
from a throwaway view model, opts back into awaiting inbox so its count
stays accurate.
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 51 |
1 files changed, 45 insertions, 6 deletions
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 7779bea..23b2d80 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -199,6 +199,7 @@ final class HomeViewModel { private let projectService: ProjectService private let ticketFetchConcurrencyLimit = 6 private let inboxUnreadConcurrencyLimit = 4 + private var inboxRefreshTask: Task<Void, Never>? private var currentUserKey: String { currentUser.canonicalName @@ -348,7 +349,19 @@ final class HomeViewModel { /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns /// whatever is already cached and only schedules a background fetch, so new /// mail cannot show up on the first pull. - func loadDashboard(forceRefresh: Bool = false) async { + /// + /// The inbox unread count is deliberately kept off this method's critical + /// path: it paginates every thread of every subscribed list and costs + /// ~25s, which — because `.refreshable` binds the spinner to this method + /// returning — would hold the pull-to-refresh spinner for the whole time. + /// Instead it refreshes in a detached background task and updates the badge + /// when it lands, so the spinner returns as soon as the fast branches settle. + /// + /// Callers that build a persisted snapshot from a throwaway view model — + /// the widget / App Intents needs-attention refresh — cannot let the inbox + /// fetch outlive them, so they pass `awaitInboxUnread: true` to fold it back + /// onto the critical path and get an accurate count before this returns. + func loadDashboard(forceRefresh: Bool = false, awaitInboxUnread: Bool = false) async { isLoadingProjects = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true @@ -362,7 +375,6 @@ final class HomeViewModel { async let projectsTask = loadProjects(forceRefresh: forceRefresh) async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh) async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh) - async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh) async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh) // Resolve system status first so the Home title-bar status badge can @@ -413,13 +425,40 @@ final class HomeViewModel { } isLoadingAssignedTickets = false - let inboxUnreadSnapshot = await inboxUnreadTask - unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount - unreadInboxThreads = inboxUnreadSnapshot?.threads ?? [] - hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 lastRefreshed = Date() persistNeedsAttentionSnapshot() persistSystemStatusWidgetSnapshot() + + if awaitInboxUnread { + inboxRefreshTask?.cancel() + inboxRefreshTask = nil + applyInboxUnreadSnapshot(await loadInboxUnreadSnapshot(forceRefresh: forceRefresh)) + } else { + refreshInboxUnreadInBackground(forceRefresh: forceRefresh) + } + } + + /// Refreshes the inbox unread badge without blocking `loadDashboard`. + /// + /// Any in-flight refresh is cancelled so overlapping pulls don't stack + /// ~25s fetches. On cancellation the snapshot loader returns `nil`; the + /// early `Task.isCancelled` check then leaves the existing badge untouched + /// rather than wiping it, so a superseding refresh owns the final value. + private func refreshInboxUnreadInBackground(forceRefresh: Bool) { + inboxRefreshTask?.cancel() + inboxRefreshTask = Task { [weak self] in + guard let self else { return } + let snapshot = await self.loadInboxUnreadSnapshot(forceRefresh: forceRefresh) + guard !Task.isCancelled else { return } + self.applyInboxUnreadSnapshot(snapshot) + } + } + + private func applyInboxUnreadSnapshot(_ snapshot: HomeInboxUnreadSnapshot?) { + unreadInboxThreadCount = snapshot?.unreadCount + unreadInboxThreads = snapshot?.threads ?? [] + hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0 + persistNeedsAttentionSnapshot() } /// Returns true if sufficient time has elapsed since the last dashboard refresh. |
