krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.0: Hutch/Networking/MailingListActivity.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct ListEmailsResponse: Decodable, Sendable {
6 let list: ListEmailsPayload?
7}
8
9private struct ListEmailsPayload: Decodable, Sendable {
10 let emails: ListEmailPage
11}
12
13private struct ListEmailPage: Decodable, Sendable {
14 let results: [ListEmailPayload]
15 let cursor: String?
16}
17
18private struct ListEmailPayload: Decodable, Sendable {
19 /// When sr.ht received the mail. Unlike `date`, which comes from the sender's
20 /// Date: header and is both nullable and not to be trusted, this is
21 /// server-authoritative.
22 let received: Date
23 let thread: ListEmailThread
24}
25
26private struct ListEmailThread: Decodable, Sendable {
27 let root: ListEmailThreadRoot
28}
29
30private struct ListEmailThreadRoot: Decodable, Sendable {
31 let id: Int
32}
33
34// MARK: - Activity
35
36/// When each thread on a mailing list last received mail.
37///
38/// `Thread.updated` cannot answer this. Despite its name, and despite the schema
39/// describing threads as ordered "most recently bumped", it is the root email's
40/// insert time and never advances when a reply arrives — sr.ht reports `updated`
41/// seven seconds after `root.date` on a thread carrying four replies. Anything
42/// built on it silently treats thread creation as activity.
43///
44/// `MailingList.emails` is reverse-chronological arrival data, so it can.
45struct MailingListActivity: Sendable {
46 private let newestByRootEmailID: [Int: Date]
47
48 init(newestByRootEmailID: [Int: Date] = [:]) {
49 self.newestByRootEmailID = newestByRootEmailID
50 }
51
52 /// The newest arrival in the thread rooted at `rootEmailID`.
53 ///
54 /// Falls back to `fallback` for threads with nothing inside the scanned
55 /// window, which are by definition older than the cutoff and therefore read.
56 func lastActivity(rootEmailID: Int, fallback: Date) -> Date {
57 guard let newest = newestByRootEmailID[rootEmailID] else { return fallback }
58 return max(newest, fallback)
59 }
60}
61
62enum MailingListActivityLoader {
63
64 private static let listEmailsQuery = """
65 query listActivity($rid: ID!, $cursor: Cursor) {
66 list(rid: $rid) {
67 emails(cursor: $cursor) {
68 results {
69 received
70 thread { root { id } }
71 }
72 cursor
73 }
74 }
75 }
76 """
77
78 /// Scans the list's mail newest-first and stops once it is older than
79 /// `cutoff`, so a quiet list costs a single page and a busy one costs only
80 /// what has arrived since.
81 ///
82 /// `maxPages` bounds the scan. An account carrying pre-existing read state has
83 /// a `distantPast` cutoff, which would otherwise walk the entire archive;
84 /// threads beyond the window keep their fallback date and stay read, which is
85 /// what they already were.
86 ///
87 /// Returns empty activity on failure rather than throwing: unread is a
88 /// decoration, and losing it should not fail the thread list around it.
89 static func load(
90 client: SRHTClient,
91 listRID: String,
92 since cutoff: Date,
93 maxPages: Int = 3
94 ) async -> MailingListActivity {
95 var newest: [Int: Date] = [:]
96 var cursor: String?
97 var pagesFetched = 0
98
99 while pagesFetched < maxPages {
100 var variables: [String: any Sendable] = ["rid": listRID]
101 if let cursor {
102 variables["cursor"] = cursor
103 }
104
105 let response: ListEmailsResponse
106 do {
107 response = try await client.execute(
108 service: .lists,
109 query: listEmailsQuery,
110 variables: variables,
111 responseType: ListEmailsResponse.self
112 )
113 } catch {
114 return MailingListActivity(newestByRootEmailID: newest)
115 }
116
117 guard let page = response.list?.emails else { break }
118 pagesFetched += 1
119
120 for email in page.results {
121 let rootID = email.thread.root.id
122 if let existing = newest[rootID], existing >= email.received { continue }
123 newest[rootID] = email.received
124 }
125
126 // Reverse chronological, so once a page ends older than the cutoff
127 // nothing further back can matter.
128 if let oldest = page.results.map(\.received).min(), oldest <= cutoff { break }
129 guard let next = page.cursor, !next.isEmpty else { break }
130 cursor = next
131 }
132
133 return MailingListActivity(newestByRootEmailID: newest)
134 }
135}