krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.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}