1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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)
}
}
|