Commit 53ade2af0a
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +3
| @@ -561,6 +561,9 @@ final class AppState { | |||
| 561 | UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID) | 561 | UserDefaults.standard.set(session.account.id, forKey: AppStorageKeys.activeAccountID) |
| 562 | ActiveAccountContextStore.save(session.account.id) | 562 | ActiveAccountContextStore.save(session.account.id) |
| 563 | ContributionWidgetContextStore.saveActor(session.user.canonicalName, accountID: session.account.id) | 563 | 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) | ||
| 564 | authStatusMessage = "Connecting…" | 567 | authStatusMessage = "Connecting…" |
| 565 | } | 568 | } |
| 566 | 569 | ||
Hutch/Models/Inbox.swift +41 −4
| @@ -170,6 +170,7 @@ struct InboxPatchPreview: Decodable, Sendable, Hashable { | |||
| 170 | 170 | ||
| 171 | enum InboxReadStateStore { | 171 | enum InboxReadStateStore { |
| 172 | private static let key = "InboxThreadLastViewed" | 172 | private static let key = "InboxThreadLastViewed" |
| 173 | private static let baselineKey = "InboxUnreadBaseline" | ||
| 173 | 174 | ||
| 174 | static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { | 175 | static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { |
| 175 | guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], | 176 | guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], |
| @@ -185,16 +186,52 @@ enum InboxReadStateStore { | |||
| 185 | defaults.set(dictionary, forKey: key) | 186 | defaults.set(dictionary, forKey: key) |
| 186 | } | 187 | } |
| 187 | 188 | ||
| 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. | ||
| 188 | static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { | 195 | static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { |
| 189 | var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] | 196 | var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] |
| 190 | dictionary.removeValue(forKey: threadID) | 197 | dictionary[threadID] = Date.distantPast.timeIntervalSince1970 |
| 191 | defaults.set(dictionary, forKey: key) | 198 | defaults.set(dictionary, forKey: key) |
| 192 | } | 199 | } |
| 193 | 200 | ||
| 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 | |||
| 194 | static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { | 228 | static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { |
| 195 | guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { | 229 | if let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) { |
| 196 | return true | 230 | return lastActivityAt > lastViewedAt |
| 231 | } | ||
| 232 | if let baseline = baseline(defaults: defaults), lastActivityAt <= baseline { | ||
| 233 | return false | ||
| 197 | } | 234 | } |
| 198 | return lastActivityAt > lastViewedAt | 235 | return true |
| 199 | } | 236 | } |
| 200 | } | 237 | } |
HutchTests/InboxViewModelTests.swift +104
| @@ -41,6 +41,110 @@ struct InboxViewModelTests { | |||
| 41 | #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) | 41 | #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 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 | |||
| 44 | @Test | 148 | @Test |
| 45 | func normalizesThreadSubjectsForDisplay() { | 149 | func normalizesThreadSubjectsForDisplay() { |
| 46 | let summary = InboxThreadSummary( | 150 | let summary = InboxThreadSummary( |