diff options
| -rw-r--r-- | Hutch/Models/Inbox.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 48 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxThreadUtilities.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxViewModel.swift | 417 | ||||
| -rw-r--r-- | HutchTests/HomeViewModelTests.swift | 52 | ||||
| -rw-r--r-- | HutchTests/InboxViewModelTests.swift | 55 |
6 files changed, 112 insertions, 473 deletions
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index 8ab2480..071bcbc 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -51,7 +51,7 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable { "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)" } - var threadGroupingKey: String { + nonisolated var threadGroupingKey: String { "\(listRID)#\(Self.normalizationKey(for: subject))" } diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 6016a11..7ef82e9 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -758,9 +758,11 @@ final class HomeViewModel { throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)]) } + let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads) + return HomeInboxUnreadSnapshot( - unreadCount: unreadCount, - threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage) + unreadCount: deduplicatedThreads.count, + threads: deduplicatedThreads ) } @@ -824,7 +826,7 @@ final class HomeViewModel { latestSender: thread.sender, lastActivityAt: thread.updated, messageCount: thread.replies + 1, - repo: InboxViewModel.deriveRepositoryName(from: mailingList.name), + repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name), containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), isUnread: InboxReadStateStore.isUnread( threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))", @@ -1099,6 +1101,46 @@ final class HomeViewModel { .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending } + nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { + var grouped: [String: InboxThreadSummary] = [:] + + for thread in threads { + guard let existing = grouped[thread.threadGroupingKey] else { + grouped[thread.threadGroupingKey] = thread + continue + } + + let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing + let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() + let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() + let mergedMessageCount = max( + existing.messageCount ?? existing.threadRootMessageIDs.count, + thread.messageCount ?? thread.threadRootMessageIDs.count, + mergedRootMessageIDs.count + ) + + grouped[thread.threadGroupingKey] = InboxThreadSummary( + rootEmailID: latest.rootEmailID, + rootMessageID: latest.rootMessageID, + threadRootEmailIDs: mergedRootEmailIDs, + threadRootMessageIDs: mergedRootMessageIDs, + listID: latest.listID, + listRID: latest.listRID, + listName: latest.listName, + listOwner: latest.listOwner, + subject: latest.subject, + latestSender: latest.latestSender, + lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), + messageCount: mergedMessageCount, + repo: latest.repo ?? existing.repo, + containsPatch: latest.containsPatch || existing.containsPatch, + isUnread: latest.isUnread || existing.isUnread + ) + } + + return grouped.values.sorted(by: sortInboxThreadsForTriage) + } + nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) diff --git a/Hutch/Views/Inbox/InboxThreadUtilities.swift b/Hutch/Views/Inbox/InboxThreadUtilities.swift new file mode 100644 index 0000000..1dd88a3 --- /dev/null +++ b/Hutch/Views/Inbox/InboxThreadUtilities.swift @@ -0,0 +1,11 @@ +import Foundation + +enum InboxThreadUtilities { + nonisolated static func deriveRepositoryName(from listName: String) -> String? { + let separators = ["-devel", "-patches", "-dev", ".patches"] + for separator in separators where listName.hasSuffix(separator) { + return String(listName.dropLast(separator.count)) + } + return nil + } +} diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift deleted file mode 100644 index 8fff2dc..0000000 --- a/Hutch/Views/Inbox/InboxViewModel.swift +++ /dev/null @@ -1,417 +0,0 @@ -import Foundation -import os - -private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList") - -private struct InboxSubscriptionsResponse: Decodable, Sendable { - let subscriptions: InboxSubscriptionPage -} - -private struct InboxSubscriptionPage: Decodable, Sendable { - let results: [InboxActivitySubscription] - let cursor: String? -} - -private struct InboxActivitySubscription: Decodable, Sendable { - let id: Int - let created: Date - let list: InboxMailingListReference? - - enum CodingKeys: String, CodingKey { - case id - case created - case list - } -} - -private struct InboxListThreadsResponse: Decodable, Sendable { - let list: InboxMailingListThreads -} - -private struct InboxMailingListThreads: Decodable, Sendable { - let threads: InboxThreadPage -} - -private struct InboxThreadPage: Decodable, Sendable { - let results: [InboxThreadPayload] - let cursor: String? -} - -private struct InboxThreadPayload: Decodable, Sendable { - let created: Date - let updated: Date - let subject: String - let replies: Int - let sender: Entity - let root: InboxEmailPreview -} - -private struct InboxEmailPreview: Decodable, Sendable { - let id: Int - let subject: String - let date: Date? - let received: Date - let messageID: String - let body: String - let patch: InboxPatchPreview? -} - -enum InboxThreadFilter: String, CaseIterable, Sendable { - case all = "All" - case patches = "Patches" - case discussions = "Talk" -} - -@Observable -@MainActor -final class InboxViewModel { - private(set) var threads: [InboxThreadSummary] = [] - private(set) var isLoading = false - var error: String? - var filter: InboxThreadFilter = .all - var searchText = "" - - private let client: SRHTClient - private let defaults: UserDefaults - private let accountID: String - private let listThreadFetchLimit = 10 - private let listFetchConcurrencyLimit = 4 - - private static let subscriptionsQuery = """ - query inboxSubscriptions($cursor: Cursor) { - subscriptions(cursor: $cursor) { - results { - ... on MailingListSubscription { - id - created - list { - id - rid - name - owner { canonicalName } - } - } - } - cursor - } - } - """ - - private static let listThreadsQuery = """ - query inboxListThreads($rid: ID!, $cursor: Cursor) { - list(rid: $rid) { - threads(cursor: $cursor) { - results { - created - updated - subject - replies - sender { canonicalName } - root { - id - subject - date - received - messageID - body - patch { subject } - } - } - cursor - } - } - } - """ - - init(client: SRHTClient, defaults: UserDefaults, accountID: String) { - self.client = client - self.defaults = defaults - self.accountID = accountID - } - - func loadThreads() async { - guard !isLoading else { return } - isLoading = true - error = nil - defer { isLoading = false } - - do { - let subscriptions = try await fetchSubscriptions() - let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) - let fetchedThreads = try await fetchThreads(for: mailingLists) - threads = fetchedThreads - .filter(\.isUnread) - .sorted { lhs, rhs in - if lhs.lastActivityAt == rhs.lastActivityAt { - return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending - } - return lhs.lastActivityAt > rhs.lastActivityAt - } - NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID) - } catch { - inboxListLogger.error("Inbox request failed") - self.error = "Failed to load inbox" - } - } - - func markThreadRead(_ thread: InboxThreadSummary) { - let viewedAt = max(Date(), thread.lastActivityAt) - InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults) - threads.removeAll { $0.id == thread.id } - NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID) - } - - func markAllThreadsRead() { - guard !threads.isEmpty else { return } - - let viewedAt = Date() - for thread in threads where thread.isUnread { - InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults) - } - - threads.removeAll { $0.isUnread } - NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID) - } - - func markThreadUnread(_ thread: InboxThreadSummary) { - InboxReadStateStore.markUnread(for: thread.id, defaults: defaults) - updateThread(thread, isUnread: true) - NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID) - } - - func toggleThreadReadState(_ thread: InboxThreadSummary) { - if thread.isUnread { - markThreadRead(thread) - } else { - markThreadUnread(thread) - } - } - - func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? { - threads.first(where: { $0.id == id }) - } - - var filteredThreads: [InboxThreadSummary] { - let filteredByKind = Self.filterThreads(threads, filter: filter) - let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return filteredByKind } - return filteredByKind.filter { - $0.displaySubject.lowercased().contains(q) || - $0.listName.lowercased().contains(q) || - $0.latestSender.canonicalName.lowercased().contains(q) - } - } - - var hasUnreadThreads: Bool { - threads.contains(where: \.isUnread) - } - - private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { - var subscriptions: [InboxActivitySubscription] = [] - var cursor: String? - - while true { - var variables: [String: any Sendable] = [:] - if let cursor { - variables["cursor"] = cursor - } - - let response = try await client.execute( - service: .lists, - query: Self.subscriptionsQuery, - variables: variables.isEmpty ? nil : variables, - responseType: InboxSubscriptionsResponse.self - ) - - subscriptions.append(contentsOf: response.subscriptions.results) - guard let nextCursor = response.subscriptions.cursor else { - break - } - cursor = nextCursor - } - - return subscriptions - } - - private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] { - guard !mailingLists.isEmpty else { return [] } - - var summaries: [InboxThreadSummary] = [] - var startIndex = mailingLists.startIndex - var failureMessages: [String] = [] - - while startIndex < mailingLists.endIndex { - let endIndex = mailingLists.index( - startIndex, - offsetBy: listFetchConcurrencyLimit, - limitedBy: mailingLists.endIndex - ) ?? mailingLists.endIndex - let batch = Array(mailingLists[startIndex..<endIndex]) - - let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in - for mailingList in batch { - group.addTask { - do { - return (try await self.fetchThreads(for: mailingList), nil) - } catch { - return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)") - } - } - } - - var batchSummaries: [InboxThreadSummary] = [] - var batchFailures: [String] = [] - for await result in group { - batchSummaries.append(contentsOf: result.0) - if let failure = result.1 { - batchFailures.append(failure) - } - } - return (batchSummaries, batchFailures) - } - - summaries.append(contentsOf: batchResult.0) - failureMessages.append(contentsOf: batchResult.1) - for failure in batchResult.1 { - inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)") - } - startIndex = endIndex - } - - if summaries.isEmpty, let firstFailure = failureMessages.first { - throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)]) - } - - return deduplicateThreads(summaries) - } - - private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] { - let response = try await client.execute( - service: .lists, - query: Self.listThreadsQuery, - variables: ["rid": mailingList.rid], - responseType: InboxListThreadsResponse.self - ) - - return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in - 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())" - let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults) - return InboxThreadSummary( - rootEmailID: thread.root.id, - rootMessageID: thread.root.messageID, - threadRootEmailIDs: [thread.root.id], - threadRootMessageIDs: [thread.root.messageID], - listID: mailingList.id, - listRID: mailingList.rid, - listName: mailingList.name, - listOwner: mailingList.owner, - subject: thread.subject, - latestSender: thread.sender, - lastActivityAt: thread.updated, - messageCount: thread.replies + 1, - repo: Self.deriveRepositoryName(from: mailingList.name), - containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), - isUnread: isUnread - ) - } - } - - private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { - var grouped: [String: InboxThreadSummary] = [:] - - for thread in threads { - guard let existing = grouped[thread.threadGroupingKey] else { - grouped[thread.threadGroupingKey] = thread - continue - } - - let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing - let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() - let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() - let mergedMessageCount = max( - existing.messageCount ?? existing.threadRootMessageIDs.count, - thread.messageCount ?? thread.threadRootMessageIDs.count, - mergedRootMessageIDs.count - ) - - grouped[thread.threadGroupingKey] = InboxThreadSummary( - rootEmailID: latest.rootEmailID, - rootMessageID: latest.rootMessageID, - threadRootEmailIDs: mergedRootEmailIDs, - threadRootMessageIDs: mergedRootMessageIDs, - listID: latest.listID, - listRID: latest.listRID, - listName: latest.listName, - listOwner: latest.listOwner, - subject: latest.subject, - latestSender: latest.latestSender, - lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), - messageCount: mergedMessageCount, - repo: latest.repo ?? existing.repo, - containsPatch: latest.containsPatch || existing.containsPatch, - isUnread: latest.isUnread || existing.isUnread - ) - } - - return grouped.values.sorted { lhs, rhs in - if lhs.lastActivityAt == rhs.lastActivityAt { - return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending - } - return lhs.lastActivityAt > rhs.lastActivityAt - } - } - - private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { - guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } - let current = threads[index] - if !isUnread { - threads.remove(at: index) - return - } - threads[index] = InboxThreadSummary( - rootEmailID: current.rootEmailID, - rootMessageID: current.rootMessageID, - threadRootEmailIDs: current.threadRootEmailIDs, - threadRootMessageIDs: current.threadRootMessageIDs, - listID: current.listID, - listRID: current.listRID, - listName: current.listName, - listOwner: current.listOwner, - subject: current.subject, - latestSender: current.latestSender, - lastActivityAt: current.lastActivityAt, - messageCount: current.messageCount, - repo: current.repo, - containsPatch: current.containsPatch, - isUnread: isUnread - ) - } - - private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { - var seen = Set<String>() - return mailingLists.filter { mailingList in - seen.insert(mailingList.rid).inserted - } - } - - nonisolated static func deriveRepositoryName(from listName: String) -> String? { - let separators = ["-devel", "-patches", "-dev", ".patches"] - for separator in separators where listName.hasSuffix(separator) { - return String(listName.dropLast(separator.count)) - } - return nil - } - - nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] { - threads.filter { thread in - switch filter { - case .all: - return true - case .patches: - return thread.containsPatch - case .discussions: - return !thread.containsPatch - } - } - } -} diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift index fe4229f..e4f6d9a 100644 --- a/HutchTests/HomeViewModelTests.swift +++ b/HutchTests/HomeViewModelTests.swift @@ -90,6 +90,58 @@ struct HomeViewModelTests { } @Test + func deduplicateInboxThreadsCollapsesMessagesIntoOneThreadSummary() { + let list = InboxMailingListReference( + id: 1, + rid: "list", + name: "hutch-devel", + owner: Entity(canonicalName: "~owner") + ) + let first = InboxThreadSummary( + rootEmailID: 10, + rootMessageID: "message-1", + threadRootEmailIDs: [10], + threadRootMessageIDs: ["message-1"], + listID: list.id, + listRID: list.rid, + listName: list.name, + listOwner: list.owner, + subject: "Re: [PATCH] add search", + latestSender: Entity(canonicalName: "~alice"), + lastActivityAt: Date(timeIntervalSince1970: 100), + messageCount: 1, + repo: "hutch", + containsPatch: true, + isUnread: true + ) + let second = InboxThreadSummary( + rootEmailID: 11, + rootMessageID: "message-2", + threadRootEmailIDs: [11], + threadRootMessageIDs: ["message-2"], + listID: list.id, + listRID: list.rid, + listName: list.name, + listOwner: list.owner, + subject: "[PATCH] add search", + latestSender: Entity(canonicalName: "~bob"), + lastActivityAt: Date(timeIntervalSince1970: 200), + messageCount: 2, + repo: "hutch", + containsPatch: true, + isUnread: true + ) + + let deduplicated = HomeViewModel.deduplicateInboxThreads([first, second]) + + #expect(deduplicated.count == 1) + #expect(deduplicated[0].rootEmailID == 11) + #expect(deduplicated[0].threadRootEmailIDs == [10, 11]) + #expect(deduplicated[0].threadRootMessageIDs == ["message-1", "message-2"]) + #expect(deduplicated[0].messageCount == 2) + } + + @Test func matchesCurrentUserAssigneeNormalizesCanonicalNameAndUsername() { let currentUser = User( id: 42, diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift index a5a0428..7bfc199 100644 --- a/HutchTests/InboxViewModelTests.swift +++ b/HutchTests/InboxViewModelTests.swift @@ -6,9 +6,9 @@ struct InboxViewModelTests { @Test func derivesRepositoryNameFromCommonPatchListSuffixes() { - #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut") - #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git") - #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil) + #expect(InboxThreadUtilities.deriveRepositoryName(from: "hut-devel") == "hut") + #expect(InboxThreadUtilities.deriveRepositoryName(from: "git.patches") == "git") + #expect(InboxThreadUtilities.deriveRepositoryName(from: "discuss") == nil) } @Test @@ -166,55 +166,6 @@ struct InboxViewModelTests { } @Test - func inboxFilterSeparatesPatchThreadsFromDiscussionThreads() { - let baseList = InboxMailingListReference( - id: 1, - rid: "list", - name: "hutch-devel", - owner: Entity(canonicalName: "~owner") - ) - let patchThread = InboxThreadSummary( - rootEmailID: 10, - rootMessageID: "message-1", - threadRootEmailIDs: [10], - threadRootMessageIDs: ["message-1"], - listID: baseList.id, - listRID: baseList.rid, - listName: baseList.name, - listOwner: baseList.owner, - subject: "[PATCH] add search", - latestSender: Entity(canonicalName: "~alice"), - lastActivityAt: Date(timeIntervalSince1970: 100), - messageCount: 1, - repo: "hutch", - containsPatch: true, - isUnread: true - ) - let discussionThread = InboxThreadSummary( - rootEmailID: 11, - rootMessageID: "message-2", - threadRootEmailIDs: [11], - threadRootMessageIDs: ["message-2"], - listID: baseList.id, - listRID: baseList.rid, - listName: baseList.name, - listOwner: baseList.owner, - subject: "release planning", - latestSender: Entity(canonicalName: "~bob"), - lastActivityAt: Date(timeIntervalSince1970: 200), - messageCount: 2, - repo: "hutch", - containsPatch: false, - isUnread: true - ) - - let threads = [patchThread, discussionThread] - - #expect(InboxViewModel.filterThreads(threads, filter: .patches).map(\.rootEmailID) == [10]) - #expect(InboxViewModel.filterThreads(threads, filter: .discussions).map(\.rootEmailID) == [11]) - } - - @Test func segmentsPatchBodyAndTreatsSignatureAsPlainText() { let body = """ From: Christian Cleberg <[email protected]> |
