summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 23:18:20 -0500
committerChristian Cleberg <[email protected]>2026-07-15 23:18:20 -0500
commit97e8b49fcb56751fa0e80a332484db8252753417 (patch)
tree917e5224e64b990617e8ce0f4ee36afc4cc052bd /Hutch
parent91023a6572354af7770309715d1d77e978298b0d (diff)
downloadhutch-97e8b49fcb56751fa0e80a332484db8252753417.tar.gz
hutch-97e8b49fcb56751fa0e80a332484db8252753417.tar.bz2
hutch-97e8b49fcb56751fa0e80a332484db8252753417.zip
fix: derive inbox activity from mail, not thread.updated
Thread.updated is the root email's insert time. It never advances when a reply arrives, despite the name and despite the schema describing threads as ordered "most recently bumped". sr.ht returns updated seven seconds after root.date on a thread carrying four replies: subject: "Re: test post" updated: 2026-07-16T03:44:55Z replies: 4 root: { date: 2026-07-15T22:44:48-05:00 } The app fed that field into lastActivityAt everywhere, so a reply to an old thread was invisible: it did not mark the thread unread, did not raise it in the list, and did not update the timestamp beside it. This was inert until the unread baseline landed, because an absent view record returned unread unconditionally and the date was never compared to anything. The baseline did not break unread; it was the first code to depend on that field meaning what it says. Use MailingList.emails instead, which is real reverse-chronological arrival data, keyed by thread root id. The scan stops once it is older than the cutoff, so a quiet list costs one page and a busy one costs only what is new. Threads with nothing in the window keep the root timestamp and stay read, which they already were. Failure returns empty activity rather than throwing: unread is a decoration and should not take the thread list down with it. Uses Email.received rather than Email.date — received is server-side and non-null, date comes from the sender's header and is neither.
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Networking/MailingListActivity.swift135
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift13
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift21
3 files changed, 163 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)
)
}