Commit e1f56d98a0

e1f56d98a07a0308c4cd3e1d80c5e9c26a7323de

parent: 134442898a

Unsigned

cmc <hello@cleberg.net> · 2026-04-20 18:15 UTC

fix: collapse work inbox messages into thread summaries

Layout: unified · split

Hutch/Models/Inbox.swift +1 −1
@@ -51,7 +51,7 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
5151 "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
5252 }
5353
54 var threadGroupingKey: String {
54 nonisolated var threadGroupingKey: String {
5555 "\(listRID)#\(Self.normalizationKey(for: subject))"
5656 }
5757
Hutch/Views/Home/HomeViewModel.swift +45 −3
@@ -758,9 +758,11 @@ final class HomeViewModel {
758758 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
759759 }
760760
761 let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads)
762
761763 return HomeInboxUnreadSnapshot(
762 unreadCount: unreadCount,
763 threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
764 unreadCount: deduplicatedThreads.count,
765 threads: deduplicatedThreads
764766 )
765767 }
766768
@@ -824,7 +826,7 @@ final class HomeViewModel {
824826 latestSender: thread.sender,
825827 lastActivityAt: thread.updated,
826828 messageCount: thread.replies + 1,
827 repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
829 repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name),
828830 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
829831 isUnread: InboxReadStateStore.isUnread(
830832 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
@@ -1099,6 +1101,46 @@ final class HomeViewModel {
10991101 .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
11001102 }
11011103
1104 nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
1105 var grouped: [String: InboxThreadSummary] = [:]
1106
1107 for thread in threads {
1108 guard let existing = grouped[thread.threadGroupingKey] else {
1109 grouped[thread.threadGroupingKey] = thread
1110 continue
1111 }
1112
1113 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
1114 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
1115 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
1116 let mergedMessageCount = max(
1117 existing.messageCount ?? existing.threadRootMessageIDs.count,
1118 thread.messageCount ?? thread.threadRootMessageIDs.count,
1119 mergedRootMessageIDs.count
1120 )
1121
1122 grouped[thread.threadGroupingKey] = InboxThreadSummary(
1123 rootEmailID: latest.rootEmailID,
1124 rootMessageID: latest.rootMessageID,
1125 threadRootEmailIDs: mergedRootEmailIDs,
1126 threadRootMessageIDs: mergedRootMessageIDs,
1127 listID: latest.listID,
1128 listRID: latest.listRID,
1129 listName: latest.listName,
1130 listOwner: latest.listOwner,
1131 subject: latest.subject,
1132 latestSender: latest.latestSender,
1133 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
1134 messageCount: mergedMessageCount,
1135 repo: latest.repo ?? existing.repo,
1136 containsPatch: latest.containsPatch || existing.containsPatch,
1137 isUnread: latest.isUnread || existing.isUnread
1138 )
1139 }
1140
1141 return grouped.values.sorted(by: sortInboxThreadsForTriage)
1142 }
1143
11021144 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
11031145 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
11041146 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
Hutch/Views/Inbox/InboxThreadUtilities.swift added +11
@@ -0,0 +1,11 @@
1import Foundation
2
3enum InboxThreadUtilities {
4 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
5 let separators = ["-devel", "-patches", "-dev", ".patches"]
6 for separator in separators where listName.hasSuffix(separator) {
7 return String(listName.dropLast(separator.count))
8 }
9 return nil
10 }
11}
Hutch/Views/Inbox/InboxViewModel.swift deleted −417
@@ -1,417 +0,0 @@
1import Foundation
2import os
3
4private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
5
6private struct InboxSubscriptionsResponse: Decodable, Sendable {
7 let subscriptions: InboxSubscriptionPage
8}
9
10private struct InboxSubscriptionPage: Decodable, Sendable {
11 let results: [InboxActivitySubscription]
12 let cursor: String?
13}
14
15private struct InboxActivitySubscription: Decodable, Sendable {
16 let id: Int
17 let created: Date
18 let list: InboxMailingListReference?
19
20 enum CodingKeys: String, CodingKey {
21 case id
22 case created
23 case list
24 }
25}
26
27private struct InboxListThreadsResponse: Decodable, Sendable {
28 let list: InboxMailingListThreads
29}
30
31private struct InboxMailingListThreads: Decodable, Sendable {
32 let threads: InboxThreadPage
33}
34
35private struct InboxThreadPage: Decodable, Sendable {
36 let results: [InboxThreadPayload]
37 let cursor: String?
38}
39
40private struct InboxThreadPayload: Decodable, Sendable {
41 let created: Date
42 let updated: Date
43 let subject: String
44 let replies: Int
45 let sender: Entity
46 let root: InboxEmailPreview
47}
48
49private struct InboxEmailPreview: Decodable, Sendable {
50 let id: Int
51 let subject: String
52 let date: Date?
53 let received: Date
54 let messageID: String
55 let body: String
56 let patch: InboxPatchPreview?
57}
58
59enum InboxThreadFilter: String, CaseIterable, Sendable {
60 case all = "All"
61 case patches = "Patches"
62 case discussions = "Talk"
63}
64
65@Observable
66@MainActor
67final class InboxViewModel {
68 private(set) var threads: [InboxThreadSummary] = []
69 private(set) var isLoading = false
70 var error: String?
71 var filter: InboxThreadFilter = .all
72 var searchText = ""
73
74 private let client: SRHTClient
75 private let defaults: UserDefaults
76 private let accountID: String
77 private let listThreadFetchLimit = 10
78 private let listFetchConcurrencyLimit = 4
79
80 private static let subscriptionsQuery = """
81 query inboxSubscriptions($cursor: Cursor) {
82 subscriptions(cursor: $cursor) {
83 results {
84 ... on MailingListSubscription {
85 id
86 created
87 list {
88 id
89 rid
90 name
91 owner { canonicalName }
92 }
93 }
94 }
95 cursor
96 }
97 }
98 """
99
100 private static let listThreadsQuery = """
101 query inboxListThreads($rid: ID!, $cursor: Cursor) {
102 list(rid: $rid) {
103 threads(cursor: $cursor) {
104 results {
105 created
106 updated
107 subject
108 replies
109 sender { canonicalName }
110 root {
111 id
112 subject
113 date
114 received
115 messageID
116 body
117 patch { subject }
118 }
119 }
120 cursor
121 }
122 }
123 }
124 """
125
126 init(client: SRHTClient, defaults: UserDefaults, accountID: String) {
127 self.client = client
128 self.defaults = defaults
129 self.accountID = accountID
130 }
131
132 func loadThreads() async {
133 guard !isLoading else { return }
134 isLoading = true
135 error = nil
136 defer { isLoading = false }
137
138 do {
139 let subscriptions = try await fetchSubscriptions()
140 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
141 let fetchedThreads = try await fetchThreads(for: mailingLists)
142 threads = fetchedThreads
143 .filter(\.isUnread)
144 .sorted { lhs, rhs in
145 if lhs.lastActivityAt == rhs.lastActivityAt {
146 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
147 }
148 return lhs.lastActivityAt > rhs.lastActivityAt
149 }
150 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
151 } catch {
152 inboxListLogger.error("Inbox request failed")
153 self.error = "Failed to load inbox"
154 }
155 }
156
157 func markThreadRead(_ thread: InboxThreadSummary) {
158 let viewedAt = max(Date(), thread.lastActivityAt)
159 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
160 threads.removeAll { $0.id == thread.id }
161 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
162 }
163
164 func markAllThreadsRead() {
165 guard !threads.isEmpty else { return }
166
167 let viewedAt = Date()
168 for thread in threads where thread.isUnread {
169 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
170 }
171
172 threads.removeAll { $0.isUnread }
173 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
174 }
175
176 func markThreadUnread(_ thread: InboxThreadSummary) {
177 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
178 updateThread(thread, isUnread: true)
179 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
180 }
181
182 func toggleThreadReadState(_ thread: InboxThreadSummary) {
183 if thread.isUnread {
184 markThreadRead(thread)
185 } else {
186 markThreadUnread(thread)
187 }
188 }
189
190 func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
191 threads.first(where: { $0.id == id })
192 }
193
194 var filteredThreads: [InboxThreadSummary] {
195 let filteredByKind = Self.filterThreads(threads, filter: filter)
196 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
197 guard !q.isEmpty else { return filteredByKind }
198 return filteredByKind.filter {
199 $0.displaySubject.lowercased().contains(q) ||
200 $0.listName.lowercased().contains(q) ||
201 $0.latestSender.canonicalName.lowercased().contains(q)
202 }
203 }
204
205 var hasUnreadThreads: Bool {
206 threads.contains(where: \.isUnread)
207 }
208
209 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
210 var subscriptions: [InboxActivitySubscription] = []
211 var cursor: String?
212
213 while true {
214 var variables: [String: any Sendable] = [:]
215 if let cursor {
216 variables["cursor"] = cursor
217 }
218
219 let response = try await client.execute(
220 service: .lists,
221 query: Self.subscriptionsQuery,
222 variables: variables.isEmpty ? nil : variables,
223 responseType: InboxSubscriptionsResponse.self
224 )
225
226 subscriptions.append(contentsOf: response.subscriptions.results)
227 guard let nextCursor = response.subscriptions.cursor else {
228 break
229 }
230 cursor = nextCursor
231 }
232
233 return subscriptions
234 }
235
236 private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
237 guard !mailingLists.isEmpty else { return [] }
238
239 var summaries: [InboxThreadSummary] = []
240 var startIndex = mailingLists.startIndex
241 var failureMessages: [String] = []
242
243 while startIndex < mailingLists.endIndex {
244 let endIndex = mailingLists.index(
245 startIndex,
246 offsetBy: listFetchConcurrencyLimit,
247 limitedBy: mailingLists.endIndex
248 ) ?? mailingLists.endIndex
249 let batch = Array(mailingLists[startIndex..<endIndex])
250
251 let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
252 for mailingList in batch {
253 group.addTask {
254 do {
255 return (try await self.fetchThreads(for: mailingList), nil)
256 } catch {
257 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
258 }
259 }
260 }
261
262 var batchSummaries: [InboxThreadSummary] = []
263 var batchFailures: [String] = []
264 for await result in group {
265 batchSummaries.append(contentsOf: result.0)
266 if let failure = result.1 {
267 batchFailures.append(failure)
268 }
269 }
270 return (batchSummaries, batchFailures)
271 }
272
273 summaries.append(contentsOf: batchResult.0)
274 failureMessages.append(contentsOf: batchResult.1)
275 for failure in batchResult.1 {
276 inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
277 }
278 startIndex = endIndex
279 }
280
281 if summaries.isEmpty, let firstFailure = failureMessages.first {
282 throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
283 }
284
285 return deduplicateThreads(summaries)
286 }
287
288 private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
289 let response = try await client.execute(
290 service: .lists,
291 query: Self.listThreadsQuery,
292 variables: ["rid": mailingList.rid],
293 responseType: InboxListThreadsResponse.self
294 )
295
296 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
297 let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
298 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults)
299 return InboxThreadSummary(
300 rootEmailID: thread.root.id,
301 rootMessageID: thread.root.messageID,
302 threadRootEmailIDs: [thread.root.id],
303 threadRootMessageIDs: [thread.root.messageID],
304 listID: mailingList.id,
305 listRID: mailingList.rid,
306 listName: mailingList.name,
307 listOwner: mailingList.owner,
308 subject: thread.subject,
309 latestSender: thread.sender,
310 lastActivityAt: thread.updated,
311 messageCount: thread.replies + 1,
312 repo: Self.deriveRepositoryName(from: mailingList.name),
313 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
314 isUnread: isUnread
315 )
316 }
317 }
318
319 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
320 var grouped: [String: InboxThreadSummary] = [:]
321
322 for thread in threads {
323 guard let existing = grouped[thread.threadGroupingKey] else {
324 grouped[thread.threadGroupingKey] = thread
325 continue
326 }
327
328 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
329 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
330 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
331 let mergedMessageCount = max(
332 existing.messageCount ?? existing.threadRootMessageIDs.count,
333 thread.messageCount ?? thread.threadRootMessageIDs.count,
334 mergedRootMessageIDs.count
335 )
336
337 grouped[thread.threadGroupingKey] = InboxThreadSummary(
338 rootEmailID: latest.rootEmailID,
339 rootMessageID: latest.rootMessageID,
340 threadRootEmailIDs: mergedRootEmailIDs,
341 threadRootMessageIDs: mergedRootMessageIDs,
342 listID: latest.listID,
343 listRID: latest.listRID,
344 listName: latest.listName,
345 listOwner: latest.listOwner,
346 subject: latest.subject,
347 latestSender: latest.latestSender,
348 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
349 messageCount: mergedMessageCount,
350 repo: latest.repo ?? existing.repo,
351 containsPatch: latest.containsPatch || existing.containsPatch,
352 isUnread: latest.isUnread || existing.isUnread
353 )
354 }
355
356 return grouped.values.sorted { lhs, rhs in
357 if lhs.lastActivityAt == rhs.lastActivityAt {
358 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
359 }
360 return lhs.lastActivityAt > rhs.lastActivityAt
361 }
362 }
363
364 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
365 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
366 let current = threads[index]
367 if !isUnread {
368 threads.remove(at: index)
369 return
370 }
371 threads[index] = InboxThreadSummary(
372 rootEmailID: current.rootEmailID,
373 rootMessageID: current.rootMessageID,
374 threadRootEmailIDs: current.threadRootEmailIDs,
375 threadRootMessageIDs: current.threadRootMessageIDs,
376 listID: current.listID,
377 listRID: current.listRID,
378 listName: current.listName,
379 listOwner: current.listOwner,
380 subject: current.subject,
381 latestSender: current.latestSender,
382 lastActivityAt: current.lastActivityAt,
383 messageCount: current.messageCount,
384 repo: current.repo,
385 containsPatch: current.containsPatch,
386 isUnread: isUnread
387 )
388 }
389
390 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
391 var seen = Set<String>()
392 return mailingLists.filter { mailingList in
393 seen.insert(mailingList.rid).inserted
394 }
395 }
396
397 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
398 let separators = ["-devel", "-patches", "-dev", ".patches"]
399 for separator in separators where listName.hasSuffix(separator) {
400 return String(listName.dropLast(separator.count))
401 }
402 return nil
403 }
404
405 nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] {
406 threads.filter { thread in
407 switch filter {
408 case .all:
409 return true
410 case .patches:
411 return thread.containsPatch
412 case .discussions:
413 return !thread.containsPatch
414 }
415 }
416 }
417}
HutchTests/HomeViewModelTests.swift +52
@@ -89,6 +89,58 @@ struct HomeViewModelTests {
8989 #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
9090 }
9191
92 @Test
93 func deduplicateInboxThreadsCollapsesMessagesIntoOneThreadSummary() {
94 let list = InboxMailingListReference(
95 id: 1,
96 rid: "list",
97 name: "hutch-devel",
98 owner: Entity(canonicalName: "~owner")
99 )
100 let first = InboxThreadSummary(
101 rootEmailID: 10,
102 rootMessageID: "message-1",
103 threadRootEmailIDs: [10],
104 threadRootMessageIDs: ["message-1"],
105 listID: list.id,
106 listRID: list.rid,
107 listName: list.name,
108 listOwner: list.owner,
109 subject: "Re: [PATCH] add search",
110 latestSender: Entity(canonicalName: "~alice"),
111 lastActivityAt: Date(timeIntervalSince1970: 100),
112 messageCount: 1,
113 repo: "hutch",
114 containsPatch: true,
115 isUnread: true
116 )
117 let second = InboxThreadSummary(
118 rootEmailID: 11,
119 rootMessageID: "message-2",
120 threadRootEmailIDs: [11],
121 threadRootMessageIDs: ["message-2"],
122 listID: list.id,
123 listRID: list.rid,
124 listName: list.name,
125 listOwner: list.owner,
126 subject: "[PATCH] add search",
127 latestSender: Entity(canonicalName: "~bob"),
128 lastActivityAt: Date(timeIntervalSince1970: 200),
129 messageCount: 2,
130 repo: "hutch",
131 containsPatch: true,
132 isUnread: true
133 )
134
135 let deduplicated = HomeViewModel.deduplicateInboxThreads([first, second])
136
137 #expect(deduplicated.count == 1)
138 #expect(deduplicated[0].rootEmailID == 11)
139 #expect(deduplicated[0].threadRootEmailIDs == [10, 11])
140 #expect(deduplicated[0].threadRootMessageIDs == ["message-1", "message-2"])
141 #expect(deduplicated[0].messageCount == 2)
142 }
143
92144 @Test
93145 func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() {
94146 let currentUser = User(
HutchTests/InboxViewModelTests.swift +3 −52
@@ -6,9 +6,9 @@ struct InboxViewModelTests {
66
77 @Test
88 func derivesRepositoryNameFromCommonPatchListSuffixes() {
9 #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut")
10 #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git")
11 #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil)
9 #expect(InboxThreadUtilities.deriveRepositoryName(from: "hut-devel") == "hut")
10 #expect(InboxThreadUtilities.deriveRepositoryName(from: "git.patches") == "git")
11 #expect(InboxThreadUtilities.deriveRepositoryName(from: "discuss") == nil)
1212 }
1313
1414 @Test
@@ -165,55 +165,6 @@ struct InboxViewModelTests {
165165 #expect(senderMatches.map(\.rootEmailID) == [11])
166166 }
167167
168 @Test
169 func inboxFilterSeparatesPatchThreadsFromDiscussionThreads() {
170 let baseList = InboxMailingListReference(
171 id: 1,
172 rid: "list",
173 name: "hutch-devel",
174 owner: Entity(canonicalName: "~owner")
175 )
176 let patchThread = InboxThreadSummary(
177 rootEmailID: 10,
178 rootMessageID: "message-1",
179 threadRootEmailIDs: [10],
180 threadRootMessageIDs: ["message-1"],
181 listID: baseList.id,
182 listRID: baseList.rid,
183 listName: baseList.name,
184 listOwner: baseList.owner,
185 subject: "[PATCH] add search",
186 latestSender: Entity(canonicalName: "~alice"),
187 lastActivityAt: Date(timeIntervalSince1970: 100),
188 messageCount: 1,
189 repo: "hutch",
190 containsPatch: true,
191 isUnread: true
192 )
193 let discussionThread = InboxThreadSummary(
194 rootEmailID: 11,
195 rootMessageID: "message-2",
196 threadRootEmailIDs: [11],
197 threadRootMessageIDs: ["message-2"],
198 listID: baseList.id,
199 listRID: baseList.rid,
200 listName: baseList.name,
201 listOwner: baseList.owner,
202 subject: "release planning",
203 latestSender: Entity(canonicalName: "~bob"),
204 lastActivityAt: Date(timeIntervalSince1970: 200),
205 messageCount: 2,
206 repo: "hutch",
207 containsPatch: false,
208 isUnread: true
209 )
210
211 let threads = [patchThread, discussionThread]
212
213 #expect(InboxViewModel.filterThreads(threads, filter: .patches).map(\.rootEmailID) == [10])
214 #expect(InboxViewModel.filterThreads(threads, filter: .discussions).map(\.rootEmailID) == [11])
215 }
216
217168 @Test
218169 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
219170 let body = """