From 97e8b49fcb56751fa0e80a332484db8252753417 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 15 Jul 2026 23:18:20 -0500 Subject: fix: derive inbox activity from mail, not thread.updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- HutchTests/MailingListActivityTests.swift | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 HutchTests/MailingListActivityTests.swift (limited to 'HutchTests') 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 + ) + ) + } +} -- cgit v1.2.3