Commit 17d0f6db92

17d0f6db9291ee9edd1372155dee6d87bce91bf4

parent: 53ade2af0a

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 03:51 UTC

fix: let pull to refresh actually refresh Home

New mail showed as unread inside a mailing list but not on Home.

The list view reads threads with client.execute, so it always sees the server.
Home reads them with executeCached under cacheFirstThenRefresh, which returns
the cached value and only schedules a background fetch. loadDashboard had no way
to bypass that, so pulling to refresh could not surface mail that arrived after
the last fetch — it returned the same cache it already had and Home stayed a
cycle behind.

Thread forceRefresh through loadDashboard to the cached reads, and use it from
the pull to refresh on Home and Work. Matches loadSystemStatus(forceRefresh:),
which MoreView already refreshes this way.

The staleness predates the unread baseline. It was invisible while every thread
counted as unread, because Home always had a large number to show; now that Home
correctly ignores mail older than sign-in, a stale cache reads as a truthful 0.

Layout: unified · split

Hutch/Views/Home/HomeView.swift +1 −1
@@ -74,7 +74,7 @@ struct HomeView: View {
7474 .listStyle(.insetGrouped)
7575 .listSectionSpacing(.compact)
7676 .refreshable {
77 await viewModel.loadDashboard()
77 await viewModel.loadDashboard(forceRefresh: true)
7878 }
7979 .connectivityOverlay(hasContent: hasHomeContent(viewModel)) {
8080 await viewModel.loadDashboard()
Hutch/Views/Home/HomeViewModel.swift +33 −28
@@ -343,7 +343,12 @@ final class HomeViewModel {
343343 self.accountID = accountID
344344 }
345345
346 func loadDashboard() async {
346 /// Loads the dashboard.
347 ///
348 /// `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 /// mail cannot show up on the first pull.
351 func loadDashboard(forceRefresh: Bool = false) async {
347352 isLoadingProjects = true
348353 isLoadingAssignedTickets = true
349354 isLoadingRecentBuilds = true
@@ -354,11 +359,11 @@ final class HomeViewModel {
354359 isShowingStaleSystemStatus = false
355360 systemStatusErrorMessage = nil
356361
357 async let projectsTask = loadProjects()
358 async let jobsTask = loadRecentJobs()
359 async let assignedTicketsTask = loadAssignedTickets()
360 async let inboxUnreadTask = loadInboxUnreadSnapshot()
361 async let systemStatusTask = loadSystemStatusSnapshot()
362 async let projectsTask = loadProjects(forceRefresh: forceRefresh)
363 async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
364 async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
365 async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
366 async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
362367
363368 let projectsResult = await projectsTask
364369 switch projectsResult {
@@ -676,7 +681,7 @@ final class HomeViewModel {
676681 persistNeedsAttentionSnapshot()
677682 }
678683
679 private func loadProjects() async -> Result<[Project], Error> {
684 private func loadProjects(forceRefresh: Bool) async -> Result<[Project], Error> {
680685 do {
681686 return .success(try await projectService.fetchProjects())
682687 } catch {
@@ -684,7 +689,7 @@ final class HomeViewModel {
684689 }
685690 }
686691
687 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
692 private func loadRecentJobs(forceRefresh: Bool) async -> Result<[HomeJobPayload], Error> {
688693 do {
689694 let cached = try await client.executeCached(
690695 service: .builds,
@@ -693,7 +698,7 @@ final class HomeViewModel {
693698 cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName),
694699 resourceType: .buildList,
695700 ttl: APICacheTTLs.homeDashboard,
696 policy: .cacheFirstThenRefresh
701 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
697702 )
698703 return .success(cached.value.jobs.results)
699704 } catch {
@@ -701,15 +706,15 @@ final class HomeViewModel {
701706 }
702707 }
703708
704 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
709 private func loadInboxUnreadSnapshot(forceRefresh: Bool) async -> HomeInboxUnreadSnapshot? {
705710 do {
706 return try await fetchUnreadInboxSnapshot()
711 return try await fetchUnreadInboxSnapshot(forceRefresh: forceRefresh)
707712 } catch {
708713 return nil
709714 }
710715 }
711716
712 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
717 private func loadSystemStatusSnapshot(forceRefresh: Bool) async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
713718 do {
714719 return .success(try await systemStatusRepository.snapshotResult())
715720 } catch {
@@ -717,8 +722,8 @@ final class HomeViewModel {
717722 }
718723 }
719724
720 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
721 let mailingLists = try await fetchInboxMailingLists()
725 private func fetchUnreadInboxSnapshot(forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
726 let mailingLists = try await fetchInboxMailingLists(forceRefresh: forceRefresh)
722727 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
723728
724729 var startIndex = mailingLists.startIndex
@@ -737,7 +742,7 @@ final class HomeViewModel {
737742 for mailingList in batch {
738743 group.addTask {
739744 do {
740 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
745 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList, forceRefresh: forceRefresh))
741746 } catch {
742747 return .failure(error)
743748 }
@@ -776,7 +781,7 @@ final class HomeViewModel {
776781 )
777782 }
778783
779 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
784 private func fetchInboxMailingLists(forceRefresh: Bool) async throws -> [InboxMailingListReference] {
780785 var subscriptions: [HomeInboxSubscription] = []
781786 var cursor: String?
782787
@@ -794,7 +799,7 @@ final class HomeViewModel {
794799 cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor),
795800 resourceType: .ticketList,
796801 ttl: APICacheTTLs.inboxSummary,
797 policy: .cacheFirstThenRefresh
802 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
798803 )
799804 let response = cached.value
800805
@@ -809,7 +814,7 @@ final class HomeViewModel {
809814 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
810815 }
811816
812 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
817 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference, forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
813818 var unreadCount = 0
814819 var cursor: String?
815820 var unreadThreads: [InboxThreadSummary] = []
@@ -828,7 +833,7 @@ final class HomeViewModel {
828833 cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor),
829834 resourceType: .ticketList,
830835 ttl: APICacheTTLs.inboxSummary,
831 policy: .cacheFirstThenRefresh
836 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
832837 )
833838 let response = cached.value
834839
@@ -871,10 +876,10 @@ final class HomeViewModel {
871876 )
872877 }
873878
874 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
879 private func loadAssignedTickets(forceRefresh: Bool) async -> Result<[HomeAssignedTicket], Error> {
875880 do {
876 let trackers = try await fetchAllTrackers()
877 let tickets = try await fetchAssignedTickets(for: trackers)
881 let trackers = try await fetchAllTrackers(forceRefresh: forceRefresh)
882 let tickets = try await fetchAssignedTickets(for: trackers, forceRefresh: forceRefresh)
878883 .sorted(by: Self.sortAssignedTicketsForTriage)
879884 return .success(tickets)
880885 } catch {
@@ -882,7 +887,7 @@ final class HomeViewModel {
882887 }
883888 }
884889
885 private func fetchAllTrackers() async throws -> [TrackerSummary] {
890 private func fetchAllTrackers(forceRefresh: Bool) async throws -> [TrackerSummary] {
886891 var allTrackers: [TrackerSummary] = []
887892 var cursor: String?
888893
@@ -900,7 +905,7 @@ final class HomeViewModel {
900905 cacheKey: APICacheKeys.trackers(cursor: cursor),
901906 resourceType: .ticketList,
902907 ttl: APICacheTTLs.ticketList,
903 policy: .cacheFirstThenRefresh
908 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
904909 )
905910 let response = cached.value
906911
@@ -914,7 +919,7 @@ final class HomeViewModel {
914919 return allTrackers
915920 }
916921
917 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
922 private func fetchAssignedTickets(for trackers: [TrackerSummary], forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
918923 guard !trackers.isEmpty else { return [] }
919924
920925 var assignedTickets: [HomeAssignedTicket] = []
@@ -927,7 +932,7 @@ final class HomeViewModel {
927932 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
928933 for tracker in batch {
929934 group.addTask {
930 try await self.fetchAssignedTickets(for: tracker)
935 try await self.fetchAssignedTickets(for: tracker, forceRefresh: forceRefresh)
931936 }
932937 }
933938
@@ -945,7 +950,7 @@ final class HomeViewModel {
945950 return assignedTickets
946951 }
947952
948 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
953 private func fetchAssignedTickets(for tracker: TrackerSummary, forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
949954 let cached = try await client.executeCached(
950955 service: .todo,
951956 query: Self.trackerTicketsQuery,
@@ -964,7 +969,7 @@ final class HomeViewModel {
964969 ),
965970 resourceType: .ticketList,
966971 ttl: APICacheTTLs.ticketList,
967 policy: .cacheFirstThenRefresh
972 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
968973 )
969974 let response = cached.value
970975
Hutch/Views/Work/WorkView.swift +1 −1
@@ -72,7 +72,7 @@ struct WorkView: View {
7272 .themedList()
7373 .listStyle(.insetGrouped)
7474 .refreshable {
75 await viewModel.loadDashboard()
75 await viewModel.loadDashboard(forceRefresh: true)
7676 }
7777 .connectivityOverlay(hasContent: hasWorkContent(viewModel)) {
7878 await viewModel.loadDashboard()