Commit 53ade2af0a

53ade2af0a6a6679976f7a90c51cb20037bd6ea7

parent: 54c0b1dcd4

Unsigned

cmc <hello@cleberg.net> · 2026-07-16 03:38 UTC

fix: start a new account at zero unread

An absent view record read as unread, so on first sign-in every thread a list
had ever carried was unread. On a busy list that is thousands of threads the
user never intended to read, inflating the Home dashboard, the needs-attention
snapshot, and the widget with a number that means nothing.

Record a baseline when an account is activated: mail that predates it is read,
mail after it is not. activate() is the single funnel for launch validation,
account switching, and adding an account, so one call covers every sign-in path.

Accounts that already carry read state have been in use, so they get a
distantPast baseline and keep every unread thread they had — upgrading must not
silently mark a real backlog as read.

markUnread now records an explicit distantPast marker instead of deleting the
entry. Deleting would drop the thread back to the baseline rule, so marking an
old thread unread would appear to do nothing.

Layout: unified · split

Hutch/App/AppState.swift +3
@@ -561,6 +561,9 @@ final class AppState {
561561 UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID)
562562 ActiveAccountContextStore.save(session.account.id)
563563 ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id)
564 // Every sign-in path funnels through here, so this is where an account
565 // first learns which mail predates it.
566 InboxReadStateStore.establishBaselineIfNeeded(defaults: session.defaults)
564567 authStatusMessage = "Connecting…"
565568 }
566569
Hutch/Models/Inbox.swift +41 −4
@@ -170,6 +170,7 @@ struct InboxPatchPreview: Decodable, Sendable, Hashable {
170170
171171enum InboxReadStateStore {
172172 private static let key = "InboxThreadLastViewed"
173 private static let baselineKey = "InboxUnreadBaseline"
173174
174175 static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
175176 guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
@@ -185,16 +186,52 @@ enum InboxReadStateStore {
185186 defaults.set(dictionary, forKey: key)
186187 }
187188
189 /// Records an explicit unread marker rather than forgetting the thread.
190 ///
191 /// Deleting the entry would drop the thread back to the baseline rule below,
192 /// which would call anything older than the baseline read — so marking an old
193 /// thread unread would appear to do nothing. `distantPast` always compares as
194 /// older than the thread's activity, so the thread reads as unread.
188195 static func markUnread(for threadID: String, defaults: UserDefaults = .standard) {
189196 var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
190 dictionary.removeValue(forKey: threadID)
197 dictionary[threadID] = Date.distantPast.timeIntervalSince1970
191198 defaults.set(dictionary, forKey: key)
192199 }
193200
201 /// Mail that arrived before this is treated as already read.
202 static func baseline(defaults: UserDefaults = .standard) -> Date? {
203 guard let timestamp = defaults.object(forKey: baselineKey) as? TimeInterval else {
204 return nil
205 }
206 return Date(timeIntervalSince1970: timestamp)
207 }
208
209 /// Sets the point from which mail counts as unread. Called once per account,
210 /// when the account is activated.
211 ///
212 /// Without this, every thread a list has ever carried is unread on first
213 /// login, because an absent view record reads as unread. On a busy list that
214 /// is thousands of threads, none of which the user has any intention of
215 /// reading.
216 ///
217 /// An account that already has read state has been in use, so it keeps the
218 /// old behavior — a baseline of `distantPast` leaves every existing unread
219 /// thread unread rather than silently marking a real backlog as read.
220 static func establishBaselineIfNeeded(now: Date = .now, defaults: UserDefaults = .standard) {
221 guard defaults.object(forKey: baselineKey) == nil else { return }
222
223 let hasExistingReadState = !((defaults.dictionary(forKey: key) as? [String: TimeInterval])?.isEmpty ?? true)
224 let baseline = hasExistingReadState ? Date.distantPast : now
225 defaults.set(baseline.timeIntervalSince1970, forKey: baselineKey)
226 }
227
194228 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
195 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
196 return true
229 if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) {
230 return lastActivityAt > lastViewedAt
231 }
232 if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline {
233 return false
197234 }
198 return lastActivityAt > lastViewedAt
235 return true
199236 }
200237}
HutchTests/InboxViewModelTests.swift +104
@@ -41,6 +41,110 @@ struct InboxViewModelTests {
4141 #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
4242 }
4343
44 @Test
45 func freshAccountTreatsExistingMailAsRead() {
46 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
47 let defaults = UserDefaults(suiteName: suiteName)!
48 defer { defaults.removePersistentDomain(forName: suiteName) }
49
50 let signIn = Date(timeIntervalSince1970: 5_000)
51 InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
52
53 // Years of list history should not land on a new user as unread.
54 #expect(
55 !InboxReadStateStore.isUnread(
56 threadID: "list#old",
57 lastActivityAt: Date(timeIntervalSince1970: 4_000),
58 defaults: defaults
59 )
60 )
61 }
62
63 @Test
64 func mailArrivingAfterSignInIsUnread() {
65 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
66 let defaults = UserDefaults(suiteName: suiteName)!
67 defer { defaults.removePersistentDomain(forName: suiteName) }
68
69 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
70
71 #expect(
72 InboxReadStateStore.isUnread(
73 threadID: "list#new",
74 lastActivityAt: Date(timeIntervalSince1970: 6_000),
75 defaults: defaults
76 )
77 )
78 }
79
80 @Test
81 func mailExactlyAtTheBaselineIsRead() {
82 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
83 let defaults = UserDefaults(suiteName: suiteName)!
84 defer { defaults.removePersistentDomain(forName: suiteName) }
85
86 let signIn = Date(timeIntervalSince1970: 5_000)
87 InboxReadStateStore.establishBaselineIfNeeded(now: signIn, defaults: defaults)
88
89 #expect(!InboxReadStateStore.isUnread(threadID: "list#edge", lastActivityAt: signIn, defaults: defaults))
90 }
91
92 @Test
93 func baselineIsEstablishedOnceAndNotMovedBySubsequentSignIns() {
94 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
95 let defaults = UserDefaults(suiteName: suiteName)!
96 defer { defaults.removePersistentDomain(forName: suiteName) }
97
98 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
99 // A later launch must not silently mark the backlog read.
100 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 9_000), defaults: defaults)
101
102 #expect(
103 InboxReadStateStore.isUnread(
104 threadID: "list#since",
105 lastActivityAt: Date(timeIntervalSince1970: 6_000),
106 defaults: defaults
107 )
108 )
109 }
110
111 @Test
112 func existingAccountsKeepTheirUnreadBacklog() {
113 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
114 let defaults = UserDefaults(suiteName: suiteName)!
115 defer { defaults.removePersistentDomain(forName: suiteName) }
116
117 // An account already carrying read state has been in use, so upgrading
118 // must not retroactively mark everything it had not read as read.
119 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: "list#seen", defaults: defaults)
120 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
121
122 #expect(
123 InboxReadStateStore.isUnread(
124 threadID: "list#unseen",
125 lastActivityAt: Date(timeIntervalSince1970: 4_000),
126 defaults: defaults
127 )
128 )
129 }
130
131 @Test
132 func markingAnOldThreadUnreadSurvivesTheBaseline() {
133 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
134 let defaults = UserDefaults(suiteName: suiteName)!
135 defer { defaults.removePersistentDomain(forName: suiteName) }
136
137 InboxReadStateStore.establishBaselineIfNeeded(now: Date(timeIntervalSince1970: 5_000), defaults: defaults)
138 let oldActivity = Date(timeIntervalSince1970: 4_000)
139
140 #expect(!InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
141
142 // Explicitly marking it unread must stick, rather than falling back to the
143 // baseline rule and reading as read again.
144 InboxReadStateStore.markUnread(for: "list#old", defaults: defaults)
145 #expect(InboxReadStateStore.isUnread(threadID: "list#old", lastActivityAt: oldActivity, defaults: defaults))
146 }
147
44148 @Test
45149 func normalizesThreadSubjectsForDisplay() {
46150 let summary = InboxThreadSummary(