krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

380fb866d090c5f13b2ad3f88f1fe23c4ade4801

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-20T18:15:04Z

fix: collapse work inbox messages into thread summaries
 Hutch/Models/Inbox.swift                     |   2 +-
 Hutch/Views/Home/HomeViewModel.swift         |  48 ++-
 Hutch/Views/Inbox/InboxThreadUtilities.swift |  11 +
 Hutch/Views/Inbox/InboxViewModel.swift       | 417 ---------------------------
 HutchTests/HomeViewModelTests.swift          |  52 ++++
 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
@@ -89,6 +89,58 @@ struct HomeViewModelTests {
         #expect(HomeViewModel.failedBuildLookbackDays(defaults: defaults) == HomeViewModel.defaultFailedBuildLookbackDays)
     }
 
+    @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(
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
@@ -165,55 +165,6 @@ struct InboxViewModelTests {
         #expect(senderMatches.map(\.rootEmailID) == [11])
     }
 
-    @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 = """