aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/App/AppState.swift3
-rw-r--r--Hutch/Models/Inbox.swift45
-rw-r--r--HutchTests/InboxViewModelTests.swift104
3 files changed, 148 insertions, 4 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 479f610..ad31c5c 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -561,6 +561,9 @@ final class AppState {
UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
ActiveAccountContextStore.save(session.account.id)
ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
+ // Every sign-in path funnels through here, so this is where an account
+ // first learns which mail predates it.
+ InboxReadStateStore.establishBaselineIfNeeded(defaults: session.defaults)
authStatusMessage = "Connecting…"
}
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
index be2e105..c8e41bb 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -170,6 +170,7 @@ struct InboxPatchPreview: Decodable, Sendable, Hashable {
enum InboxReadStateStore {
private static let key = "InboxThreadLastViewed"
+ private static let baselineKey = "InboxUnreadBaseline"
static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
@@ -185,16 +186,52 @@ enum InboxReadStateStore {
defaults.set(dictionary, forKey: key)
}
+ /// Records an explicit unread marker rather than forgetting the thread.
+ ///
+ /// Deleting the entry would drop the thread back to the baseline rule below,
+ /// which would call anything older than the baseline read — so marking an old
+ /// thread unread would appear to do nothing. `distantPast` always compares as
+ /// older than the thread's activity, so the thread reads as unread.
static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
- dictionary.removeValue(forKey: threadID)
+ dictionary[threadID] = Date.distantPast.timeIntervalSince1970
defaults.set(dictionary, forKey: key)
}
+ /// Mail that arrived before this is treated as already read.
+ static func baseline(defaults: UserDefaults = .standard) -> Date? {
+ guard let timestamp = defaults.object(forKey: baselineKey) as? TimeInterval else {
+ return nil
+ }
+ return Date(timeIntervalSince1970: timestamp)
+ }
+
+ /// Sets the point from which mail counts as unread. Called once per account,
+ /// when the account is activated.
+ ///
+ /// Without this, every thread a list has ever carried is unread on first
+ /// login, because an absent view record reads as unread. On a busy list that
+ /// is thousands of threads, none of which the user has any intention of
+ /// reading.
+ ///
+ /// An account that already has read state has been in use, so it keeps the
+ /// old behavior — a baseline of `distantPast` leaves every existing unread
+ /// thread unread rather than silently marking a real backlog as read.
+ static func establishBaselineIfNeeded(now: Date = .now, defaults: UserDefaults = .standard) {
+ guard defaults.object(forKey: baselineKey) == nil else { return }
+
+ let hasExistingReadState = !((defaults.dictionary(forKey: key) as? [String: TimeInterval])?.isEmpty ?? true)
+ let baseline = hasExistingReadState ? Date.distantPast : now
+ defaults.set(baseline.timeIntervalSince1970, forKey: baselineKey)
+ }
+
static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
- guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
- return true
+ if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) {
+ return lastActivityAt > lastViewedAt
+ }
+ if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline {
+ return false
}
- return lastActivityAt > lastViewedAt
+ return true
}
}
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
index 7bfc199..92f380a 100644
--- a/HutchTests/InboxViewModelTests.swift
+++ b/HutchTests/InboxViewModelTests.swift
@@ -42,6 +42,110 @@ struct InboxViewModelTests {
}
@Test
+ func freshAccountTreatsExistingMailAsRead() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let signIn = Date(timeIntervalSince1970: 5_000)
+ InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
+
+ // Years of list history should not land on a new user as unread.
+ #expect(
+ !InboxReadStateStore.isUnread(
+ threadID: "list#old",
+ lastActivityAt: Date(timeIntervalSince1970: 4_000),
+ defaults: defaults
+ )
+ )
+ }
+
+ @Test
+ func mailArrivingAfterSignInIsUnread() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
+
+ #expect(
+ InboxReadStateStore.isUnread(
+ threadID: "list#new",
+ lastActivityAt: Date(timeIntervalSince1970: 6_000),
+ defaults: defaults
+ )
+ )
+ }
+
+ @Test
+ func mailExactlyAtTheBaselineIsRead() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let signIn = Date(timeIntervalSince1970: 5_000)
+ InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
+
+ #expect(!InboxReadStateStore.isUnread(threadID: "list#edge", lastActivityAt: signIn, defaults: defaults))
+ }
+
+ @Test
+ func baselineIsEstablishedOnceAndNotMovedBySubsequentSignIns() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
+ // A later launch must not silently mark the backlog read.
+ InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 9_000), defaults: defaults)
+
+ #expect(
+ InboxReadStateStore.isUnread(
+ threadID: "list#since",
+ lastActivityAt: Date(timeIntervalSince1970: 6_000),
+ defaults: defaults
+ )
+ )
+ }
+
+ @Test
+ func existingAccountsKeepTheirUnreadBacklog() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ // An account already carrying read state has been in use, so upgrading
+ // must not retroactively mark everything it had not read as read.
+ InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: "list#seen", defaults: defaults)
+ InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
+
+ #expect(
+ InboxReadStateStore.isUnread(
+ threadID: "list#unseen",
+ lastActivityAt: Date(timeIntervalSince1970: 4_000),
+ defaults: defaults
+ )
+ )
+ }
+
+ @Test
+ func markingAnOldThreadUnreadSurvivesTheBaseline() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
+ let oldActivity = Date(timeIntervalSince1970: 4_000)
+
+ #expect(!InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
+
+ // Explicitly marking it unread must stick, rather than falling back to the
+ // baseline rule and reading as read again.
+ InboxReadStateStore.markUnread(for: "list#old", defaults: defaults)
+ #expect(InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
+ }
+
+ @Test
func normalizesThreadSubjectsForDisplay() {
let summary = InboxThreadSummary(
rootEmailID: 1,