diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 23:27:03 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 23:27:03 -0500 |
| commit | 0ad430c79eedac4140680e3aaca238969ac711b1 (patch) | |
| tree | e4928ae266acb2282a12e8212b82635890f50089 /Hutch | |
| parent | 922502a2c74c66034f3ec2db6612f2a36d242042 (diff) | |
| parent | 97e8b49fcb56751fa0e80a332484db8252753417 (diff) | |
| download | hutch-0ad430c79eedac4140680e3aaca238969ac711b1.tar.gz hutch-0ad430c79eedac4140680e3aaca238969ac711b1.tar.bz2 hutch-0ad430c79eedac4140680e3aaca238969ac711b1.zip | |
Merge pull request #5 from zerolabsco/inbox-unread-baseline
Start a new account at zero unread
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/AppState.swift | 3 | ||||
| -rw-r--r-- | Hutch/Models/Inbox.swift | 45 | ||||
| -rw-r--r-- | Hutch/Networking/MailingListActivity.swift | 135 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 74 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 21 | ||||
| -rw-r--r-- | Hutch/Views/Work/WorkView.swift | 2 |
7 files changed, 242 insertions, 40 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 479f610..ad31c5c 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -561,6 +561,9 @@ final class AppState { UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID) ActiveAccountContextStore.save(session.account.id) ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id) + // Every sign-in path funnels through here, so this is where an account + // first learns which mail predates it. + InboxReadStateStore.establishBaselineIfNeeded(defaults: session.defaults) authStatusMessage = "Connecting…" } diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index be2e105..c8e41bb 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -170,6 +170,7 @@ struct InboxPatchPreview: Decodable, Sendable, Hashable { enum InboxReadStateStore { private static let key = "InboxThreadLastViewed" + private static let baselineKey = "InboxUnreadBaseline" static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], @@ -185,16 +186,52 @@ enum InboxReadStateStore { defaults.set(dictionary, forKey: key) } + /// Records an explicit unread marker rather than forgetting the thread. + /// + /// Deleting the entry would drop the thread back to the baseline rule below, + /// which would call anything older than the baseline read — so marking an old + /// thread unread would appear to do nothing. `distantPast` always compares as + /// older than the thread's activity, so the thread reads as unread. static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] - dictionary.removeValue(forKey: threadID) + dictionary[threadID] = Date.distantPast.timeIntervalSince1970 defaults.set(dictionary, forKey: key) } + /// Mail that arrived before this is treated as already read. + static func baseline(defaults: UserDefaults = .standard) -> Date? { + guard let timestamp = defaults.object(forKey: baselineKey) as? TimeInterval else { + return nil + } + return Date(timeIntervalSince1970: timestamp) + } + + /// Sets the point from which mail counts as unread. Called once per account, + /// when the account is activated. + /// + /// Without this, every thread a list has ever carried is unread on first + /// login, because an absent view record reads as unread. On a busy list that + /// is thousands of threads, none of which the user has any intention of + /// reading. + /// + /// An account that already has read state has been in use, so it keeps the + /// old behavior — a baseline of `distantPast` leaves every existing unread + /// thread unread rather than silently marking a real backlog as read. + static func establishBaselineIfNeeded(now: Date = .now, defaults: UserDefaults = .standard) { + guard defaults.object(forKey: baselineKey) == nil else { return } + + let hasExistingReadState = !((defaults.dictionary(forKey: key) as? [String: TimeInterval])?.isEmpty ?? true) + let baseline = hasExistingReadState ? Date.distantPast : now + defaults.set(baseline.timeIntervalSince1970, forKey: baselineKey) + } + static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { - guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { - return true + if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) { + return lastActivityAt > lastViewedAt + } + if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline { + return false } - return lastActivityAt > lastViewedAt + return true } } diff --git a/Hutch/Networking/MailingListActivity.swift b/Hutch/Networking/MailingListActivity.swift new file mode 100644 index 0000000..c52a4d8 --- /dev/null +++ b/Hutch/Networking/MailingListActivity.swift @@ -0,0 +1,135 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct ListEmailsResponse: Decodable, Sendable { + let list: ListEmailsPayload? +} + +private struct ListEmailsPayload: Decodable, Sendable { + let emails: ListEmailPage +} + +private struct ListEmailPage: Decodable, Sendable { + let results: [ListEmailPayload] + let cursor: String? +} + +private struct ListEmailPayload: Decodable, Sendable { + /// When sr.ht received the mail. Unlike `date`, which comes from the sender's + /// Date: header and is both nullable and not to be trusted, this is + /// server-authoritative. + let received: Date + let thread: ListEmailThread +} + +private struct ListEmailThread: Decodable, Sendable { + let root: ListEmailThreadRoot +} + +private struct ListEmailThreadRoot: Decodable, Sendable { + let id: Int +} + +// MARK: - Activity + +/// When each thread on a mailing list last received mail. +/// +/// `Thread.updated` cannot answer this. Despite its name, and despite the schema +/// describing threads as ordered "most recently bumped", it is the root email's +/// insert time and never advances when a reply arrives — sr.ht reports `updated` +/// seven seconds after `root.date` on a thread carrying four replies. Anything +/// built on it silently treats thread creation as activity. +/// +/// `MailingList.emails` is reverse-chronological arrival data, so it can. +struct MailingListActivity: Sendable { + private let newestByRootEmailID: [Int: Date] + + init(newestByRootEmailID: [Int: Date] = [:]) { + self.newestByRootEmailID = newestByRootEmailID + } + + /// The newest arrival in the thread rooted at `rootEmailID`. + /// + /// Falls back to `fallback` for threads with nothing inside the scanned + /// window, which are by definition older than the cutoff and therefore read. + func lastActivity(rootEmailID: Int, fallback: Date) -> Date { + guard let newest = newestByRootEmailID[rootEmailID] else { return fallback } + return max(newest, fallback) + } +} + +enum MailingListActivityLoader { + + private static let listEmailsQuery = """ + query listActivity($rid: ID!, $cursor: Cursor) { + list(rid: $rid) { + emails(cursor: $cursor) { + results { + received + thread { root { id } } + } + cursor + } + } + } + """ + + /// Scans the list's mail newest-first and stops once it is older than + /// `cutoff`, so a quiet list costs a single page and a busy one costs only + /// what has arrived since. + /// + /// `maxPages` bounds the scan. An account carrying pre-existing read state has + /// a `distantPast` cutoff, which would otherwise walk the entire archive; + /// threads beyond the window keep their fallback date and stay read, which is + /// what they already were. + /// + /// Returns empty activity on failure rather than throwing: unread is a + /// decoration, and losing it should not fail the thread list around it. + static func load( + client: SRHTClient, + listRID: String, + since cutoff: Date, + maxPages: Int = 3 + ) async -> MailingListActivity { + var newest: [Int: Date] = [:] + var cursor: String? + var pagesFetched = 0 + + while pagesFetched < maxPages { + var variables: [String: any Sendable] = ["rid": listRID] + if let cursor { + variables["cursor"] = cursor + } + + let response: ListEmailsResponse + do { + response = try await client.execute( + service: .lists, + query: listEmailsQuery, + variables: variables, + responseType: ListEmailsResponse.self + ) + } catch { + return MailingListActivity(newestByRootEmailID: newest) + } + + guard let page = response.list?.emails else { break } + pagesFetched += 1 + + for email in page.results { + let rootID = email.thread.root.id + if let existing = newest[rootID], existing >= email.received { continue } + newest[rootID] = email.received + } + + // Reverse chronological, so once a page ends older than the cutoff + // nothing further back can matter. + if let oldest = page.results.map(\.received).min(), oldest <= cutoff { break } + guard let next = page.cursor, !next.isEmpty else { break } + cursor = next + } + + return MailingListActivity(newestByRootEmailID: newest) + } +} diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 409d095..3e95f0b 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -74,7 +74,7 @@ struct HomeView: View { .listStyle(.insetGrouped) .listSectionSpacing(.compact) .refreshable { - await viewModel.loadDashboard() + await viewModel.loadDashboard(forceRefresh: true) } .connectivityOverlay(hasContent: hasHomeContent(viewModel)) { await viewModel.loadDashboard() diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 4436f0c..a3aed30 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -343,7 +343,12 @@ final class HomeViewModel { self.accountID = accountID } - func loadDashboard() async { + /// Loads the dashboard. + /// + /// `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 { isLoadingProjects = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true @@ -354,11 +359,11 @@ final class HomeViewModel { isShowingStaleSystemStatus = false systemStatusErrorMessage = nil - async let projectsTask = loadProjects() - async let jobsTask = loadRecentJobs() - async let assignedTicketsTask = loadAssignedTickets() - async let inboxUnreadTask = loadInboxUnreadSnapshot() - async let systemStatusTask = loadSystemStatusSnapshot() + 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) let projectsResult = await projectsTask switch projectsResult { @@ -676,7 +681,7 @@ final class HomeViewModel { persistNeedsAttentionSnapshot() } - private func loadProjects() async -> Result<[Project], Error> { + private func loadProjects(forceRefresh: Bool) async -> Result<[Project], Error> { do { return .success(try await projectService.fetchProjects()) } catch { @@ -684,7 +689,7 @@ final class HomeViewModel { } } - private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { + private func loadRecentJobs(forceRefresh: Bool) async -> Result<[HomeJobPayload], Error> { do { let cached = try await client.executeCached( service: .builds, @@ -693,7 +698,7 @@ final class HomeViewModel { cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName), resourceType: .buildList, ttl: APICacheTTLs.homeDashboard, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) return .success(cached.value.jobs.results) } catch { @@ -701,15 +706,15 @@ final class HomeViewModel { } } - private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? { + private func loadInboxUnreadSnapshot(forceRefresh: Bool) async -> HomeInboxUnreadSnapshot? { do { - return try await fetchUnreadInboxSnapshot() + return try await fetchUnreadInboxSnapshot(forceRefresh: forceRefresh) } catch { return nil } } - private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> { + private func loadSystemStatusSnapshot(forceRefresh: Bool) async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> { do { return .success(try await systemStatusRepository.snapshotResult()) } catch { @@ -717,8 +722,8 @@ final class HomeViewModel { } } - private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot { - let mailingLists = try await fetchInboxMailingLists() + private func fetchUnreadInboxSnapshot(forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot { + let mailingLists = try await fetchInboxMailingLists(forceRefresh: forceRefresh) guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) } var startIndex = mailingLists.startIndex @@ -737,7 +742,7 @@ final class HomeViewModel { for mailingList in batch { group.addTask { do { - return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList)) + return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList, forceRefresh: forceRefresh)) } catch { return .failure(error) } @@ -776,7 +781,7 @@ final class HomeViewModel { ) } - private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] { + private func fetchInboxMailingLists(forceRefresh: Bool) async throws -> [InboxMailingListReference] { var subscriptions: [HomeInboxSubscription] = [] var cursor: String? @@ -794,7 +799,7 @@ final class HomeViewModel { cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor), resourceType: .ticketList, ttl: APICacheTTLs.inboxSummary, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) let response = cached.value @@ -809,11 +814,19 @@ final class HomeViewModel { return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted } } - private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot { + private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference, forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot { var unreadCount = 0 var cursor: String? var unreadThreads: [InboxThreadSummary] = [] + // thread.updated is the root email's insert time and never advances when a + // reply lands, so activity has to come from the list's mail feed. + let activity = await MailingListActivityLoader.load( + client: client, + listRID: mailingList.rid, + since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast + ) + while true { var variables: [String: any Sendable] = ["rid": mailingList.rid] if let cursor { @@ -828,11 +841,12 @@ final class HomeViewModel { cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor), resourceType: .ticketList, ttl: APICacheTTLs.inboxSummary, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) let response = cached.value let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in + let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated) let summary = InboxThreadSummary( rootEmailID: thread.root.id, rootMessageID: thread.root.messageID, @@ -844,13 +858,13 @@ final class HomeViewModel { listOwner: mailingList.owner, subject: thread.subject, latestSender: thread.sender, - lastActivityAt: thread.updated, + lastActivityAt: lastActivityAt, messageCount: thread.replies + 1, repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name), containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), isUnread: InboxReadStateStore.isUnread( threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", - lastActivityAt: thread.updated, + lastActivityAt: lastActivityAt, defaults: defaults ) ) @@ -871,10 +885,10 @@ final class HomeViewModel { ) } - private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> { + private func loadAssignedTickets(forceRefresh: Bool) async -> Result<[HomeAssignedTicket], Error> { do { - let trackers = try await fetchAllTrackers() - let tickets = try await fetchAssignedTickets(for: trackers) + let trackers = try await fetchAllTrackers(forceRefresh: forceRefresh) + let tickets = try await fetchAssignedTickets(for: trackers, forceRefresh: forceRefresh) .sorted(by: Self.sortAssignedTicketsForTriage) return .success(tickets) } catch { @@ -882,7 +896,7 @@ final class HomeViewModel { } } - private func fetchAllTrackers() async throws -> [TrackerSummary] { + private func fetchAllTrackers(forceRefresh: Bool) async throws -> [TrackerSummary] { var allTrackers: [TrackerSummary] = [] var cursor: String? @@ -900,7 +914,7 @@ final class HomeViewModel { cacheKey: APICacheKeys.trackers(cursor: cursor), resourceType: .ticketList, ttl: APICacheTTLs.ticketList, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) let response = cached.value @@ -914,7 +928,7 @@ final class HomeViewModel { return allTrackers } - private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] { + private func fetchAssignedTickets(for trackers: [TrackerSummary], forceRefresh: Bool) async throws -> [HomeAssignedTicket] { guard !trackers.isEmpty else { return [] } var assignedTickets: [HomeAssignedTicket] = [] @@ -927,7 +941,7 @@ final class HomeViewModel { let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in for tracker in batch { group.addTask { - try await self.fetchAssignedTickets(for: tracker) + try await self.fetchAssignedTickets(for: tracker, forceRefresh: forceRefresh) } } @@ -945,7 +959,7 @@ final class HomeViewModel { return assignedTickets } - private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { + private func fetchAssignedTickets(for tracker: TrackerSummary, forceRefresh: Bool) async throws -> [HomeAssignedTicket] { let cached = try await client.executeCached( service: .todo, query: Self.trackerTicketsQuery, @@ -964,7 +978,7 @@ final class HomeViewModel { ), resourceType: .ticketList, ttl: APICacheTTLs.ticketList, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) let response = cached.value diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index 696cf6b..10bb19e 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -104,8 +104,14 @@ final class MailingListDetailViewModel { responseType: ProjectMailingListThreadsResponse.self ) + let activity = await MailingListActivityLoader.load( + client: client, + listRID: mailingList.rid, + since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast + ) + threads = deduplicateThreads( - response.list.threads.results.map(makeSummary(from:)) + response.list.threads.results.map { makeSummary(from: $0, activity: activity) } ) patchsets = Self.patchsets(from: response.list.threads.results) } catch { @@ -193,7 +199,10 @@ final class MailingListDetailViewModel { NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID) } - private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary { + private func makeSummary( + from thread: ProjectMailingListThreadPayload, + activity: MailingListActivity + ) -> InboxThreadSummary { let normalizedSubject = thread.subject .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -201,6 +210,10 @@ final class MailingListDetailViewModel { .lowercased() let threadID = "\(mailingList.rid)#\(normalizedSubject)" + // thread.updated is the root email's insert time and never advances when a + // reply lands, so activity has to come from the list's mail feed. + let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated) + return InboxThreadSummary( rootEmailID: thread.root.id, rootMessageID: thread.root.messageID, @@ -212,11 +225,11 @@ final class MailingListDetailViewModel { listOwner: mailingList.owner, subject: thread.subject, latestSender: thread.sender, - lastActivityAt: thread.updated, + lastActivityAt: lastActivityAt, messageCount: thread.replies + 1, repo: nil, containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), - isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults) + isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivityAt, defaults: defaults) ) } diff --git a/Hutch/Views/Work/WorkView.swift b/Hutch/Views/Work/WorkView.swift index 6ba1d0c..23acdfb 100644 --- a/Hutch/Views/Work/WorkView.swift +++ b/Hutch/Views/Work/WorkView.swift @@ -72,7 +72,7 @@ struct WorkView: View { .themedList() .listStyle(.insetGrouped) .refreshable { - await viewModel.loadDashboard() + await viewModel.loadDashboard(forceRefresh: true) } .connectivityOverlay(hasContent: hasWorkContent(viewModel)) { await viewModel.loadDashboard() |
