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/Networking | |
| 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/Networking')
| -rw-r--r-- | Hutch/Networking/MailingListActivity.swift | 135 |
1 files changed, 135 insertions, 0 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) + } +} |
