diff options
| -rw-r--r-- | Hutch/Networking/MailingListActivity.swift | 135 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 13 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 21 | ||||
| -rw-r--r-- | HutchTests/MailingListActivityTests.swift | 77 |
4 files changed, 240 insertions, 6 deletions
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/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 98dd968..a3aed30 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -819,6 +819,14 @@ final class HomeViewModel { 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 { @@ -838,6 +846,7 @@ final class HomeViewModel { 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, @@ -849,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 ) ) diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index d466121..e4c9df4 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -84,8 +84,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) } ) } catch { self.error = "Failed to load mailing list" @@ -138,7 +144,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) @@ -146,6 +155,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, @@ -157,11 +170,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/HutchTests/MailingListActivityTests.swift b/HutchTests/MailingListActivityTests.swift new file mode 100644 index 0000000..46208cb --- /dev/null +++ b/HutchTests/MailingListActivityTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import Hutch + +struct MailingListActivityTests { + + @Test + func usesTheNewestArrivalOverTheRootTimestamp() { + // The case that started this: sr.ht reports thread.updated seven seconds + // after the root email on a thread carrying four replies, so the fallback + // must lose to real arrival data. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let newestReply = Date(timeIntervalSince1970: 5_000) + let activity = MailingListActivity(newestByRootEmailID: [42: newestReply]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == newestReply) + } + + @Test + func fallsBackForThreadsOutsideTheScannedWindow() { + // Threads with nothing new are absent from the feed scan; they keep the + // root timestamp, which is older than any cutoff and so reads as read. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let activity = MailingListActivity(newestByRootEmailID: [:]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == rootInsert) + } + + @Test + func neverGoesBackwardsFromTheFallback() { + // A root inserted after the newest scanned reply must not age the thread + // backwards. + let rootInsert = Date(timeIntervalSince1970: 9_000) + let staleReply = Date(timeIntervalSince1970: 5_000) + let activity = MailingListActivity(newestByRootEmailID: [42: staleReply]) + + #expect(activity.lastActivity(rootEmailID: 42, fallback: rootInsert) == rootInsert) + } + + @Test + func tracksThreadsIndependently() { + let activity = MailingListActivity(newestByRootEmailID: [ + 1: Date(timeIntervalSince1970: 5_000), + 2: Date(timeIntervalSince1970: 7_000) + ]) + let fallback = Date(timeIntervalSince1970: 1_000) + + #expect(activity.lastActivity(rootEmailID: 1, fallback: fallback) == Date(timeIntervalSince1970: 5_000)) + #expect(activity.lastActivity(rootEmailID: 2, fallback: fallback) == Date(timeIntervalSince1970: 7_000)) + #expect(activity.lastActivity(rootEmailID: 3, fallback: fallback) == fallback) + } + + @Test + func newMailInAnOldThreadReadsAsUnread() { + let suiteName = "MailingListActivityTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let signIn = Date(timeIntervalSince1970: 5_000) + InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults) + + // A thread rooted long before sign-in, with a reply after it. Keyed on + // thread.updated this reads as read, which was the bug. + let rootInsert = Date(timeIntervalSince1970: 1_000) + let replyAfterSignIn = Date(timeIntervalSince1970: 6_000) + let activity = MailingListActivity(newestByRootEmailID: [42: replyAfterSignIn]) + let lastActivityAt = activity.lastActivity(rootEmailID: 42, fallback: rootInsert) + + #expect( + InboxReadStateStore.isUnread( + threadID: "list#old thread", + lastActivityAt: lastActivityAt, + defaults: defaults + ) + ) + } +} |
