summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-20 13:15:04 -0500
committerChristian Cleberg <[email protected]>2026-04-20 13:15:04 -0500
commit380fb866d090c5f13b2ad3f88f1fe23c4ade4801 (patch)
treedfb90664e32c5f318a2fd1f107fed5ad9fff6e78 /Hutch
parent8b73fd9cd43753b36b3ab3ca5e4dc83a47ea3480 (diff)
downloadhutch-3.2.0.tar.gz
hutch-3.2.0.tar.bz2
hutch-3.2.0.zip
fix: collapse work inbox messages into thread summariesv3.2.0
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Models/Inbox.swift2
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift48
-rw-r--r--Hutch/Views/Inbox/InboxThreadUtilities.swift11
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift417
4 files changed, 57 insertions, 421 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
- }
- }
- }
-}