summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/Networking/MailingListActivity.swift135
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift13
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift21
-rw-r--r--HutchTests/MailingListActivityTests.swift77
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
+ )
+ )
+ }
+}