Commit e7fbb03ed2

e7fbb03ed2305dbf1a1e764a99013e1f4471cd1d

parent: 56a1acaeb5

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 06:44 UTC

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.

Layout: unified · split

Hutch/App/AppState.swift +1 −1
@@ -623,7 +623,7 @@ final class AppState {
623 defaults: accountDefaults, 623 defaults: accountDefaults,
624 accountID: activeAccountID 624 accountID: activeAccountID
625 ) 625 )
626 await viewModel.loadDashboard() 626 await viewModel.loadDashboard(awaitInboxUnread: true)
627 } 627 }
628 628
629 private func clearWebData() async { 629 private func clearWebData() async {
Hutch/Views/Home/HomeViewModel.swift +45 −6
@@ -199,6 +199,7 @@ final class HomeViewModel {
199 private let projectService: ProjectService 199 private let projectService: ProjectService
200 private let ticketFetchConcurrencyLimit = 6 200 private let ticketFetchConcurrencyLimit = 6
201 private let inboxUnreadConcurrencyLimit = 4 201 private let inboxUnreadConcurrencyLimit = 4
202 private var inboxRefreshTask: Task<Void, Never>?
202 203
203 private var currentUserKey: String { 204 private var currentUserKey: String {
204 currentUser.canonicalName 205 currentUser.canonicalName
@@ -348,7 +349,19 @@ final class HomeViewModel {
348 /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns 349 /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
349 /// whatever is already cached and only schedules a background fetch, so new 350 /// whatever is already cached and only schedules a background fetch, so new
350 /// mail cannot show up on the first pull. 351 /// mail cannot show up on the first pull.
351 func loadDashboard(forceRefresh: Bool = false) async { 352 ///
353 /// The inbox unread count is deliberately kept off this method's critical
354 /// path: it paginates every thread of every subscribed list and costs
355 /// ~25s, which — because `.refreshable` binds the spinner to this method
356 /// returning — would hold the pull-to-refresh spinner for the whole time.
357 /// Instead it refreshes in a detached background task and updates the badge
358 /// when it lands, so the spinner returns as soon as the fast branches settle.
359 ///
360 /// Callers that build a persisted snapshot from a throwaway view model —
361 /// the widget / App Intents needs-attention refresh — cannot let the inbox
362 /// fetch outlive them, so they pass `awaitInboxUnread: true` to fold it back
363 /// onto the critical path and get an accurate count before this returns.
364 func loadDashboard(forceRefresh: Bool = false, awaitInboxUnread: Bool = false) async {
352 isLoadingProjects = true 365 isLoadingProjects = true
353 isLoadingAssignedTickets = true 366 isLoadingAssignedTickets = true
354 isLoadingRecentBuilds = true 367 isLoadingRecentBuilds = true
@@ -362,7 +375,6 @@ final class HomeViewModel {
362 async let projectsTask = loadProjects(forceRefresh: forceRefresh) 375 async let projectsTask = loadProjects(forceRefresh: forceRefresh)
363 async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh) 376 async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
364 async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh) 377 async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
366 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh) 378 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
367 379
368 // Resolve system status first so the Home title-bar status badge can 380 // Resolve system status first so the Home title-bar status badge can
@@ -413,13 +425,40 @@ final class HomeViewModel {
413 } 425 }
414 isLoadingAssignedTickets = false 426 isLoadingAssignedTickets = false
415 427
416 let inboxUnreadSnapshot = await inboxUnreadTask
417 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
418 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
419 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
420 lastRefreshed = Date() 428 lastRefreshed = Date()
421 persistNeedsAttentionSnapshot() 429 persistNeedsAttentionSnapshot()
422 persistSystemStatusWidgetSnapshot() 430 persistSystemStatusWidgetSnapshot()
431
432 if awaitInboxUnread {
433 inboxRefreshTask?.cancel()
434 inboxRefreshTask = nil
435 applyInboxUnreadSnapshot(await loadInboxUnreadSnapshot(forceRefresh: forceRefresh))
436 } else {
437 refreshInboxUnreadInBackground(forceRefresh: forceRefresh)
438 }
439 }
440
441 /// Refreshes the inbox unread badge without blocking `loadDashboard`.
442 ///
443 /// Any in-flight refresh is cancelled so overlapping pulls don't stack
444 /// ~25s fetches. On cancellation the snapshot loader returns `nil`; the
445 /// early `Task.isCancelled` check then leaves the existing badge untouched
446 /// rather than wiping it, so a superseding refresh owns the final value.
447 private func refreshInboxUnreadInBackground(forceRefresh: Bool) {
448 inboxRefreshTask?.cancel()
449 inboxRefreshTask = Task { [weak self] in
450 guard let self else { return }
451 let snapshot = await self.loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
452 guard !Task.isCancelled else { return }
453 self.applyInboxUnreadSnapshot(snapshot)
454 }
455 }
456
457 private func applyInboxUnreadSnapshot(_ snapshot: HomeInboxUnreadSnapshot?) {
458 unreadInboxThreadCount = snapshot?.unreadCount
459 unreadInboxThreads = snapshot?.threads ?? []
460 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
461 persistNeedsAttentionSnapshot()
423 } 462 }
424 463
425 /// Returns true if sufficient time has elapsed since the last dashboard refresh. 464 /// Returns true if sufficient time has elapsed since the last dashboard refresh.