summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/App/AppState.swift14
-rw-r--r--Hutch/App/RootView.swift42
-rw-r--r--Hutch/Models/Inbox.swift187
-rw-r--r--Hutch/Views/Home/HomeView.swift25
-rw-r--r--Hutch/Views/Inbox/InboxView.swift107
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift335
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift324
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift702
-rw-r--r--Hutch/Views/Repositories/DiffView.swift22
-rw-r--r--HutchTests/InboxViewModelTests.swift168
10 files changed, 1897 insertions, 29 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index 624c78d..7492fa6 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -7,6 +7,15 @@ import WebKit
@MainActor
final class AppState {
+ enum Tab: Hashable {
+ case home
+ case inbox
+ case repositories
+ case builds
+ case tickets
+ case settings
+ }
+
enum AuthPhase {
/// App just launched, checking for an existing token.
case launching
@@ -25,6 +34,8 @@ final class AppState {
authPhase == .authenticated && currentUser != nil
}
+ var selectedTab: Tab = .home
+
// MARK: - Current user (populated after successful validation)
private(set) var currentUser: User?
@@ -94,6 +105,7 @@ final class AppState {
await clearWebData()
clearWebContentRenderCaches()
authPhase = .unauthenticated
+ selectedTab = .home
}
func resetAppData() async {
@@ -108,6 +120,7 @@ final class AppState {
clearWebContentRenderCaches()
authPhase = .unauthenticated
+ selectedTab = .home
}
// MARK: - Deep link resolution
@@ -208,6 +221,7 @@ final class AppState {
client.responseCache.clear()
currentUser = nil
pendingDeepLink = nil
+ selectedTab = .home
}
private func clearWebData() async {
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 79411ed..36cbe5c 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -4,17 +4,8 @@ import SwiftUI
/// full-screen sheet for token entry on first launch.
struct RootView: View {
@Environment(AppState.self) private var appState
-
- enum Tab: Hashable {
- case home
- case repositories
- case builds
- case tickets
- case settings
- }
-
- @State private var selectedTab: Tab = .home
@State private var homePath = NavigationPath()
+ @State private var inboxPath = NavigationPath()
@State private var repoPath = NavigationPath()
@State private var buildsPath = NavigationPath()
@State private var ticketsPath = NavigationPath()
@@ -48,19 +39,29 @@ struct RootView: View {
// MARK: - Tab View
private var tabContent: some View {
- TabView(selection: $selectedTab) {
+ @Bindable var appState = appState
+
+ return TabView(selection: $appState.selectedTab) {
NavigationStack(path: $homePath) {
HomeView()
}
- .tag(Tab.home)
+ .tag(AppState.Tab.home)
.tabItem {
Label("Home", systemImage: "house")
}
+ NavigationStack(path: $inboxPath) {
+ InboxView()
+ }
+ .tag(AppState.Tab.inbox)
+ .tabItem {
+ Label("Inbox", systemImage: "tray")
+ }
+
NavigationStack(path: $repoPath) {
RepositoryListView()
}
- .tag(Tab.repositories)
+ .tag(AppState.Tab.repositories)
.tabItem {
Label("Repositories", systemImage: "book.closed")
}
@@ -73,7 +74,7 @@ struct RootView: View {
BuildDetailView(jobId: jobId)
}
}
- .tag(Tab.builds)
+ .tag(AppState.Tab.builds)
.tabItem {
Label("Builds", systemImage: "hammer")
}
@@ -85,13 +86,13 @@ struct RootView: View {
TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
}
}
- .tag(Tab.tickets)
+ .tag(AppState.Tab.tickets)
.tabItem {
Label("Tickets", systemImage: "ticket")
}
SettingsView()
- .tag(Tab.settings)
+ .tag(AppState.Tab.settings)
.tabItem {
Label("Settings", systemImage: "gear")
}
@@ -117,10 +118,11 @@ struct RootView: View {
break
case .unauthenticated:
homePath = NavigationPath()
+ inboxPath = NavigationPath()
repoPath = NavigationPath()
buildsPath = NavigationPath()
ticketsPath = NavigationPath()
- selectedTab = .home
+ appState.selectedTab = .home
isResolvingDeepLink = false
case .authenticated:
consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
@@ -143,7 +145,7 @@ struct RootView: View {
case .build(let jobId):
// Reset the builds navigation and push the detail
buildsPath = NavigationPath()
- selectedTab = .builds
+ appState.selectedTab = .builds
// Defer the push slightly so the tab switch takes effect
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
@@ -162,7 +164,7 @@ struct RootView: View {
do {
let summary = try await appState.resolveRepository(owner: owner, name: repo)
repoPath = NavigationPath()
- selectedTab = .repositories
+ appState.selectedTab = .repositories
try? await Task.sleep(for: .milliseconds(100))
repoPath.append(summary)
} catch {
@@ -178,7 +180,7 @@ struct RootView: View {
do {
let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
ticketsPath = NavigationPath()
- selectedTab = .tickets
+ appState.selectedTab = .tickets
try? await Task.sleep(for: .milliseconds(100))
ticketsPath.append(trackerSummary)
try? await Task.sleep(for: .milliseconds(100))
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
new file mode 100644
index 0000000..720c71f
--- /dev/null
+++ b/Hutch/Models/Inbox.swift
@@ -0,0 +1,187 @@
+import Foundation
+
+struct InboxThreadSummary: Identifiable, Hashable, Sendable {
+ let rootEmailID: Int
+ let rootMessageID: String
+ let threadRootEmailIDs: [Int]
+ let threadRootMessageIDs: [String]
+ let listID: Int
+ let listRID: String
+ let listName: String
+ let listOwner: Entity
+ let subject: String
+ let latestSender: Entity
+ let lastActivityAt: Date
+ let messageCount: Int?
+ let repo: String?
+ let containsPatch: Bool
+ let isUnread: Bool
+
+ var id: String {
+ threadGroupingKey
+ }
+
+ var listDisplayName: String {
+ "\(listOwner.canonicalName)/\(listName)"
+ }
+
+ var displaySubject: String {
+ Self.normalizedSubject(from: subject)
+ }
+
+ var metadataLine: String {
+ var parts = [latestSenderDisplayName]
+ if let messageCount, messageCount > 1 {
+ let replyCount = max(messageCount - 1, 1)
+ parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")")
+ }
+ parts.append(lastActivityAt.relativeDescription)
+ return parts.joined(separator: " • ")
+ }
+
+ var latestSenderDisplayName: String {
+ let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if canonicalName.contains("@") {
+ return canonicalName
+ }
+ return canonicalName
+ }
+
+ var debugIdentifierSummary: String {
+ "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
+ }
+
+ var threadGroupingKey: String {
+ "\(listRID)#\(displaySubject.lowercased())"
+ }
+
+ private static func normalizedSubject(from subject: String) -> String {
+ let collapsedWhitespace = subject
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"#
+ return collapsedWhitespace.replacingOccurrences(
+ of: pattern,
+ with: "",
+ options: [.regularExpression, .caseInsensitive]
+ )
+ }
+}
+
+struct InboxMessage: Identifiable, Hashable, Sendable {
+ let id: Int
+ let author: Entity
+ let date: Date
+ let subject: String
+ let body: String
+ let senderDisplayName: String
+ let senderEmailAddress: String?
+ let isPatch: Bool
+ let contentBlocks: [InboxMessageContentBlock]
+ let rawMessageURL: URL?
+}
+
+enum InboxMessageContentBlock: Hashable, Sendable {
+ case plainText(String)
+ case diff(String)
+}
+
+struct InboxThreadDetail: Sendable {
+ let id: String
+ let rootEmailID: Int
+ let rootMessageID: String
+ let subject: String
+ let author: Entity
+ let lastActivityAt: Date
+ let mailto: String?
+ let listID: Int
+ let listRID: String
+ let listName: String
+ let listOwner: Entity
+ let messageCount: Int?
+ let messages: [InboxMessage]
+
+ var listDisplayName: String {
+ "\(listOwner.canonicalName)/\(listName)"
+ }
+}
+
+extension InboxThreadDetail {
+ var replyRecipient: String {
+ "\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
+ }
+
+ var replySubject: String {
+ subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)"
+ }
+
+ var displaySubject: String {
+ InboxThreadSummary(
+ rootEmailID: rootEmailID,
+ rootMessageID: rootMessageID,
+ threadRootEmailIDs: [rootEmailID],
+ threadRootMessageIDs: [rootMessageID],
+ listID: listID,
+ listRID: listRID,
+ listName: listName,
+ listOwner: listOwner,
+ subject: subject,
+ latestSender: author,
+ lastActivityAt: lastActivityAt,
+ messageCount: messageCount,
+ repo: nil,
+ containsPatch: messages.contains(where: \.isPatch),
+ isUnread: false
+ ).displaySubject
+ }
+}
+
+struct MailComposeDraft: Sendable {
+ let recipients: [String]
+ let ccRecipients: [String]
+ let subject: String
+ let body: String
+
+ var id: String {
+ ([subject] + recipients + ccRecipients).joined(separator: "|")
+ }
+}
+
+extension MailComposeDraft: Identifiable {}
+
+struct InboxMailingListReference: Decodable, Sendable, Hashable {
+ let id: Int
+ let rid: String
+ let name: String
+ let owner: Entity
+}
+
+struct InboxPatchPreview: Decodable, Sendable, Hashable {
+ let subject: String?
+}
+
+enum InboxReadStateStore {
+ private static let key = "InboxThreadLastViewed"
+
+ static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
+ guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
+ let timestamp = dictionary[threadID] else {
+ return nil
+ }
+ return Date(timeIntervalSince1970: timestamp)
+ }
+
+ static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
+ var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
+ dictionary[threadID] = date.timeIntervalSince1970
+ defaults.set(dictionary, forKey: key)
+ }
+
+ static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
+ guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
+ return true
+ }
+ return lastActivityAt > lastViewedAt
+ }
+}
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index a72853f..1e98039 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -115,8 +115,8 @@ struct HomeView: View {
}
}
} header: {
- HomeSectionHeader("Recent Builds") {
- BuildListView()
+ HomeSectionActionHeader("Recent Builds") {
+ appState.selectedTab = .builds
}
}
}
@@ -243,6 +243,27 @@ private struct HomeSectionHeader<Destination: View>: View {
}
}
+private struct HomeSectionActionHeader: View {
+ let title: String
+ let action: () -> Void
+
+ init(_ title: String, action: @escaping () -> Void) {
+ self.title = title
+ self.action = action
+ }
+
+ var body: some View {
+ HStack {
+ Text(title)
+ Spacer()
+ Button("See All", action: action)
+ .font(.caption.weight(.medium))
+ .buttonStyle(.plain)
+ }
+ .textCase(nil)
+ }
+}
+
private struct HomeAssignedTicketsListView: View {
let viewModel: HomeViewModel
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..e5f3619
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct InboxView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: InboxViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ }
+ }
+ .navigationTitle("Inbox")
+ .task {
+ if viewModel == nil {
+ let vm = InboxViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: InboxViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: thread) {
+ InboxThreadRow(thread: thread)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Threads",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Threads",
+ systemImage: "tray",
+ description: Text("Patch threads will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .navigationDestination(for: InboxThreadSummary.self) { thread in
+ ThreadDetailView(thread: thread) {
+ viewModel.markThreadRead(thread)
+ }
+ }
+ }
+}
+
+private struct InboxThreadRow: View {
+ let thread: InboxThreadSummary
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Circle()
+ .fill(thread.isUnread ? .blue : .clear)
+ .frame(width: 8, height: 8)
+ .padding(.top, 6)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(thread.displaySubject)
+ .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ if thread.containsPatch {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(thread.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ Text(thread.lastActivityAt.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary.opacity(0.7))
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift
new file mode 100644
index 0000000..808a5ef
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -0,0 +1,335 @@
+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?
+}
+
+@Observable
+@MainActor
+final class InboxViewModel {
+ private(set) var threads: [InboxThreadSummary] = []
+ private(set) var isLoading = false
+ var error: String?
+
+ private let client: SRHTClient
+ 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) {
+ self.client = client
+ }
+
+ 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.sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ } catch {
+ threads = []
+ self.error = error.localizedDescription
+ }
+ }
+
+ func markThreadRead(_ thread: InboxThreadSummary) {
+ let viewedAt = max(Date(), thread.lastActivityAt)
+ InboxReadStateStore.markViewed(viewedAt, for: thread.id)
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ 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: false
+ )
+ }
+
+ 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 ([], 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)
+ 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 threadID = "\(mailingList.rid)#\(thread.root.messageID)"
+ 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())"
+ inboxListLogger.debug(
+ "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)"
+ )
+ 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: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
+ )
+ }
+ }
+
+ 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 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
+ }
+}
diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift
new file mode 100644
index 0000000..3c42e7f
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -0,0 +1,324 @@
+import MessageUI
+import os
+import SwiftUI
+import UIKit
+
+private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
+
+struct ThreadDetailView: View {
+ let thread: InboxThreadSummary
+ let onViewed: () -> Void
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: ThreadViewModel?
+ @State private var replySuccessMessage: String?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading thread…")
+ }
+ }
+ .navigationTitle("Thread")
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ if viewModel == nil {
+ onViewed()
+ let vm = ThreadViewModel(summary: thread, client: appState.client)
+ viewModel = vm
+ await vm.loadThread()
+ }
+ }
+ .sheet(item: Binding(
+ get: { viewModel?.composeDraft },
+ set: { _ in viewModel?.dismissReply() }
+ )) { draft in
+ MailComposeView(draft: draft) { result in
+ switch result {
+ case .failed(let message):
+ inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
+ viewModel?.error = message
+ case .cancelled:
+ inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ case .saved:
+ inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ case .sent:
+ inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
+ replySuccessMessage = "Reply handed off to Mail."
+ Task {
+ await viewModel?.loadThread()
+ }
+ }
+ }
+ }
+ .overlay(alignment: .top) {
+ if let replySuccessMessage {
+ Text(replySuccessMessage)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.thinMaterial, in: Capsule())
+ .padding(.top, 8)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
+ .onChange(of: replySuccessMessage) { _, message in
+ guard message != nil else { return }
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(2))
+ if self.replySuccessMessage == message {
+ self.replySuccessMessage = nil
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: ThreadViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ if let thread = viewModel.thread {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(thread.displaySubject)
+ .font(.headline)
+ Text(headerMetadata(thread))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ }
+
+ ForEach(thread.messages) { message in
+ InboxMessageRow(message: message)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Reply") {
+ viewModel.prepareReply()
+ }
+ }
+ }
+ .overlay {
+ if viewModel.isLoading, viewModel.thread == nil {
+ SRHTLoadingStateView(message: "Loading thread…")
+ } else if let error = viewModel.error, viewModel.thread == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Thread",
+ message: error,
+ retryAction: { await viewModel.loadThread() }
+ )
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThread()
+ }
+ }
+
+ private func headerMetadata(_ thread: InboxThreadDetail) -> String {
+ var parts = [thread.listDisplayName]
+ if let messageCount = thread.messageCount, messageCount > 1 {
+ parts.append("\(messageCount) messages")
+ }
+ parts.append(thread.lastActivityAt.relativeDescription)
+ return parts.joined(separator: " • ")
+ }
+}
+
+private struct InboxMessageRow: View {
+ let message: InboxMessage
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(senderLine)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+ Text(message.date.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if message.isPatch {
+ Text("Patch")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
+ switch block {
+ case .plainText(let text):
+ Text(text)
+ .font(.body)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ case .diff(let diff):
+ ScrollView(.horizontal) {
+ DiffView(diff: diff)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 6)
+ .listRowSeparator(.visible)
+ }
+
+ private var senderLine: String {
+ if let email = message.senderEmailAddress,
+ email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
+ return "\(message.senderDisplayName) <\(email)>"
+ }
+ return message.senderDisplayName
+ }
+}
+
+private struct MailComposeView: UIViewControllerRepresentable {
+ let draft: MailComposeDraft
+ let onComplete: (Result) -> Void
+
+ enum Result {
+ case cancelled
+ case saved
+ case sent
+ case failed(String)
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onComplete: onComplete)
+ }
+
+ func makeUIViewController(context: Context) -> UIViewController {
+ guard MFMailComposeViewController.canSendMail() else {
+ let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
+ context.coordinator.onComplete(.failed("Mail is not configured on this device."))
+ }))
+ DispatchQueue.main.async {
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ return controller
+ }
+
+ let controller = MFMailComposeViewController()
+ controller.mailComposeDelegate = context.coordinator
+ controller.setToRecipients(draft.recipients)
+ if !draft.ccRecipients.isEmpty {
+ controller.setCcRecipients(draft.ccRecipients)
+ }
+ if !draft.subject.isEmpty {
+ controller.setSubject(draft.subject)
+ }
+ if !draft.body.isEmpty {
+ controller.setMessageBody(draft.body, isHTML: false)
+ }
+ return controller
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+
+ final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
+ let onComplete: (Result) -> Void
+
+ init(onComplete: @escaping (Result) -> Void) {
+ self.onComplete = onComplete
+ }
+
+ func mailComposeController(
+ _ controller: MFMailComposeViewController,
+ didFinishWith result: MFMailComposeResult,
+ error: Error?
+ ) {
+ if error != nil {
+ let message = error?.localizedDescription ?? "The reply could not be sent."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ return
+ }
+ switch result {
+ case .cancelled:
+ controller.dismiss(animated: true)
+ onComplete(.cancelled)
+ case .saved:
+ controller.dismiss(animated: true)
+ onComplete(.saved)
+ case .sent:
+ controller.dismiss(animated: true)
+ onComplete(.sent)
+ case .failed:
+ let message = "Mail could not send the reply from the configured iOS Mail account."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ @unknown default:
+ let message = "Mail returned an unknown result while sending the reply."
+ presentFailureAlert(on: controller, message: message)
+ onComplete(.failed(message))
+ }
+ }
+
+ private func presentFailureAlert(on controller: UIViewController, message: String) {
+ guard controller.presentedViewController == nil else { return }
+ let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ controller.present(alert, animated: true)
+ }
+ }
+}
+
+private final class MailUnavailableViewController: UIViewController {
+ private let onDismiss: () -> Void
+
+ init(onDismiss: @escaping () -> Void) {
+ self.onDismiss = onDismiss
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .systemBackground
+ navigationItem.title = "Reply"
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ barButtonSystemItem: .done,
+ target: self,
+ action: #selector(dismissSelf)
+ )
+
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.text = "Mail is not configured on this device."
+ label.textAlignment = .center
+ label.numberOfLines = 0
+ label.textColor = .secondaryLabel
+
+ view.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
+ label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+ }
+
+ @objc
+ private func dismissSelf() {
+ dismiss(animated: true)
+ onDismiss()
+ }
+}
diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift
new file mode 100644
index 0000000..10a5b59
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -0,0 +1,702 @@
+import Foundation
+import os
+
+private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
+
+private struct InboxThreadDetailResponse: Decodable, Sendable {
+ let list: InboxThreadDetailList?
+}
+
+private struct InboxThreadDetailList: Decodable, Sendable {
+ let threads: InboxThreadPayloadPage?
+}
+
+private struct InboxThreadLookupResponse: Decodable, Sendable {
+ let list: InboxThreadLookupList?
+}
+
+private struct InboxThreadLookupList: Decodable, Sendable {
+ let message: InboxThreadLookupMessage?
+}
+
+private struct InboxThreadLookupMessage: Decodable, Sendable {
+ let thread: InboxThreadPayloadDetail?
+}
+
+private struct InboxThreadPayloadDetail: Decodable, Sendable {
+ let subject: String?
+ let updated: Date?
+ let replies: Int?
+ let sender: Entity?
+ let list: InboxMailingListReference?
+ let root: InboxThreadMessagePayload?
+ let descendants: InboxThreadMessagesPage?
+}
+
+private struct InboxThreadPayloadPage: Decodable, Sendable {
+ let results: [InboxThreadPayloadDetail]
+ let cursor: String?
+}
+
+private struct InboxThreadMessagesPage: Decodable, Sendable {
+ let results: [InboxThreadMessagePayload]?
+ let cursor: String?
+}
+
+private struct InboxThreadMessagePayload: Decodable, Sendable {
+ let id: Int?
+ let sender: Entity?
+ let received: Date?
+ let date: Date?
+ let subject: String?
+ let messageID: String?
+ let body: String?
+ let rawMessage: URL?
+ let patch: InboxPatchPreview?
+}
+
+@Observable
+@MainActor
+final class ThreadViewModel {
+ private(set) var thread: InboxThreadDetail?
+ private(set) var isLoading = false
+ var error: String?
+ var composeDraft: MailComposeDraft?
+
+ private let summary: InboxThreadSummary
+ private let client: SRHTClient
+
+ private static let threadDetailQuery = """
+ query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
+ list(rid: $rid) {
+ threads(cursor: $cursor) {
+ results {
+ subject
+ updated
+ replies
+ sender { canonicalName }
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ root {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ descendants(cursor: $descCursor) {
+ results {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ cursor
+ }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let threadByMessageIDQuery = """
+ query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
+ list(rid: $rid) {
+ message(messageID: $messageID) {
+ thread {
+ subject
+ updated
+ replies
+ sender { canonicalName }
+ list {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ root {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ descendants(cursor: $descCursor) {
+ results {
+ id
+ sender { canonicalName }
+ received
+ date
+ subject
+ messageID
+ body
+ rawMessage
+ patch { subject }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ init(summary: InboxThreadSummary, client: SRHTClient) {
+ self.summary = summary
+ self.client = client
+ }
+
+ func loadThread() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
+
+ do {
+ let threadPayloads = try await fetchThreadPayloads()
+
+ guard !threadPayloads.isEmpty else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+ }
+
+ let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
+ id: summary.listID,
+ rid: summary.listRID,
+ name: summary.listName,
+ owner: summary.listOwner
+ )
+ var messagesByID: [Int: InboxMessage] = [:]
+
+ for payload in threadPayloads {
+ guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
+ continue
+ }
+ messagesByID[rootMessage.id] = rootMessage
+
+ let descendantMessages = try await fetchAllDescendantMessages(
+ initialPayload: payload,
+ candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
+ )
+ for message in descendantMessages {
+ messagesByID[message.id] = message
+ }
+ }
+
+ let messages = messagesByID.values.sorted { $0.date < $1.date }
+ guard !messages.isEmpty else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
+ }
+
+ let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
+ thread = InboxThreadDetail(
+ id: summary.id,
+ rootEmailID: summary.rootEmailID,
+ rootMessageID: summary.rootMessageID,
+ subject: latestPayload.subject ?? summary.subject,
+ author: latestPayload.sender ?? summary.latestSender,
+ lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
+ mailto: nil,
+ listID: listReference.id,
+ listRID: listReference.rid,
+ listName: listReference.name,
+ listOwner: listReference.owner,
+ messageCount: max(messages.count, summary.messageCount ?? 0),
+ messages: messages
+ )
+ } catch {
+ thread = nil
+ self.error = error.localizedDescription
+ inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
+ var payloads: [InboxThreadPayloadDetail] = []
+ var seenRoots = Set<String>()
+
+ for rootMessageID in summary.threadRootMessageIDs {
+ guard !seenRoots.contains(rootMessageID) else { continue }
+ seenRoots.insert(rootMessageID)
+ if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
+ payloads.append(payload)
+ }
+ }
+
+ if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
+ payloads.append(fallback)
+ }
+
+ return payloads
+ }
+
+ private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
+ return messageMatchedThread
+ }
+ return try await scanThreadPages(targetRootMessageID: rootMessageID)
+ }
+
+ private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
+ inboxLogger.debug(
+ "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)"
+ )
+
+ var lastLookupError: Error?
+
+ for messageID in candidateMessageIDs {
+ inboxLogger.debug(
+ "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
+ )
+
+ do {
+ let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadByMessageIDQuery,
+ variables: [
+ "rid": self.summary.listRID,
+ "messageID": messageID,
+ "descCursor": nil as String?
+ ]
+ )
+
+ if let thread = response.list?.message?.thread {
+ return thread
+ }
+ } catch let error as SRHTError {
+ switch error {
+ case .graphQLErrors(let errors):
+ let combinedMessage = errors.map(\.message).joined(separator: " | ")
+ inboxLogger.error(
+ "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
+ )
+ if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
+ lastLookupError = error
+ continue
+ }
+ throw error
+ default:
+ throw error
+ }
+ }
+ }
+
+ if let lastLookupError {
+ inboxLogger.debug(
+ "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
+ )
+ }
+ return nil
+ }
+
+ private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
+ var threadCursor: String?
+
+ while true {
+ var variables: [String: any Sendable] = ["rid": summary.listRID]
+ if let threadCursor {
+ variables["cursor"] = threadCursor
+ }
+
+ let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadDetailQuery,
+ variables: {
+ var variables = variables
+ variables["descCursor"] = nil as String?
+ return variables
+ }()
+ )
+
+ guard let threadPage = response.list?.threads else {
+ throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
+ }
+
+ let candidates = threadPage.results.map { payload in
+ "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
+ }.joined(separator: " | ")
+ inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
+
+ if let matchedThread = threadPage.results.first(where: {
+ $0.root?.messageID == targetRootMessageID ||
+ $0.root?.id == summary.rootEmailID ||
+ $0.root?.subject == summary.subject
+ }) {
+ return matchedThread
+ }
+
+ guard let nextCursor = threadPage.cursor else {
+ return nil
+ }
+ threadCursor = nextCursor
+ }
+ }
+
+ private func fetchAllDescendantMessages(
+ initialPayload: InboxThreadPayloadDetail,
+ candidateMessageIDs: [String]
+ ) async throws -> [InboxMessage] {
+ var messagesByID: [Int: InboxMessage] = [:]
+
+ for payload in initialPayload.descendants?.results ?? [] {
+ if let message = Self.message(from: payload, fallbackID: nil) {
+ messagesByID[message.id] = message
+ }
+ }
+
+ var descendantCursor = initialPayload.descendants?.cursor
+ while let currentCursor = descendantCursor {
+ guard let page = try await fetchDescendantPage(
+ cursor: currentCursor,
+ candidateMessageIDs: candidateMessageIDs
+ ) else {
+ break
+ }
+
+ for payload in page.results ?? [] {
+ if let message = Self.message(from: payload, fallbackID: nil) {
+ messagesByID[message.id] = message
+ }
+ }
+ descendantCursor = page.cursor
+ }
+
+ return messagesByID.values.sorted { $0.date < $1.date }
+ }
+
+ private func fetchDescendantPage(
+ cursor: String,
+ candidateMessageIDs: [String]
+ ) async throws -> InboxThreadMessagesPage? {
+ for messageID in candidateMessageIDs {
+ let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
+ client: client,
+ query: Self.threadByMessageIDQuery,
+ variables: [
+ "rid": summary.listRID,
+ "messageID": messageID,
+ "descCursor": cursor
+ ]
+ )
+
+ if let descendants = response.list?.message?.thread?.descendants {
+ return descendants
+ }
+ }
+
+ return nil
+ }
+
+ func prepareReply() {
+ guard let thread else {
+ error = "This thread is not ready to reply to yet."
+ return
+ }
+ inboxLogger.debug(
+ "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account"
+ )
+ composeDraft = MailComposeDraft(
+ recipients: [thread.replyRecipient],
+ ccRecipients: [],
+ subject: thread.replySubject,
+ body: ""
+ )
+ }
+
+ func dismissReply() {
+ composeDraft = nil
+ }
+
+ private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
+ guard let payload else { return nil }
+ guard let id = payload.id ?? fallbackID,
+ let author = payload.sender,
+ let date = payload.date ?? payload.received,
+ let subject = payload.subject,
+ let body = payload.body else {
+ return nil
+ }
+
+ let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
+ let displayBody = sanitizedDisplayBody(from: body)
+ let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
+
+ return InboxMessage(
+ id: id,
+ author: author,
+ date: date,
+ subject: subject,
+ body: body,
+ senderDisplayName: normalizedIdentity.displayName,
+ senderEmailAddress: normalizedIdentity.emailAddress,
+ isPatch: payload.patch != nil,
+ contentBlocks: contentBlocks,
+ rawMessageURL: payload.rawMessage
+ )
+ }
+
+ nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
+ guard let components = URLComponents(string: mailto),
+ components.scheme?.lowercased() == "mailto" else {
+ return nil
+ }
+
+ let recipients = components.path
+ .split(separator: ",")
+ .map { String($0) }
+ .filter { !$0.isEmpty }
+ let queryItems = components.queryItems ?? []
+ let ccRecipients = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
+ .value?
+ .split(separator: ",")
+ .map(String.init) ?? []
+ let subject = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
+ .value ?? ""
+ let body = queryItems
+ .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
+ .value ?? ""
+
+ return MailComposeDraft(
+ recipients: recipients,
+ ccRecipients: ccRecipients,
+ subject: subject,
+ body: body
+ )
+ }
+
+ private static func messageIDCandidates(from messageID: String) -> [String] {
+ let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedMessageID.isEmpty else { return [] }
+
+ if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
+ return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
+ }
+
+ return [trimmedMessageID, "<\(trimmedMessageID)>"]
+ }
+
+ private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
+ guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
+ return fallbackSenderIdentity(from: fallbackAuthor)
+ }
+
+ let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let start = trimmedFromLine.lastIndex(of: "<"),
+ let end = trimmedFromLine.lastIndex(of: ">"),
+ start < end {
+ let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
+ let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !name.isEmpty {
+ return (name, email.isEmpty ? nil : email)
+ }
+ return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
+ }
+
+ if trimmedFromLine.contains("@") {
+ return (trimmedFromLine, trimmedFromLine)
+ }
+
+ return (trimmedFromLine, nil)
+ }
+
+ private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
+ let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if canonicalName.contains("@") {
+ return (canonicalName, canonicalName)
+ }
+ if canonicalName.hasPrefix("~") {
+ return (String(canonicalName.dropFirst()), nil)
+ }
+ return (canonicalName, nil)
+ }
+
+ private static func sanitizedDisplayBody(from body: String) -> String {
+ let lines = body.components(separatedBy: .newlines)
+ let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
+ var headerCount = 0
+ var blankLineIndex: Int?
+
+ for (index, line) in lines.prefix(12).enumerated() {
+ if line.isEmpty {
+ blankLineIndex = index
+ break
+ }
+ if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
+ headerCount += 1
+ } else if headerCount > 0 {
+ break
+ }
+ }
+
+ guard headerCount >= 2, let blankLineIndex else {
+ return body
+ }
+
+ return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
+ }
+
+ nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+ segmentMessageBody(body, isPatch: isPatch)
+ }
+
+ private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
+ guard isPatch else {
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ let normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
+ guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
+ let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
+ }
+
+ var blocks: [InboxMessageContentBlock] = []
+ let leadingPlainText = lines[..<diffStartIndex]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !leadingPlainText.isEmpty {
+ blocks.append(.plainText(leadingPlainText))
+ }
+
+ let remainingLines = Array(lines[diffStartIndex...])
+ let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
+
+ let diffLines: ArraySlice<String>
+ let trailingPlainText: String
+ if let signatureIndex {
+ diffLines = remainingLines[..<signatureIndex]
+ trailingPlainText = remainingLines[signatureIndex...]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ diffLines = remainingLines[...]
+ trailingPlainText = ""
+ }
+
+ let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !diff.isEmpty {
+ blocks.append(.diff(diff))
+ }
+
+ if !trailingPlainText.isEmpty {
+ blocks.append(.plainText(trailingPlainText))
+ }
+ return blocks
+ }
+
+ private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
+ if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
+ return explicitDiffIndex
+ }
+
+ for index in lines.indices {
+ let line = lines[index]
+ guard line.hasPrefix("--- ") else { continue }
+ let nextIndex = lines.index(after: index)
+ guard nextIndex < lines.endIndex else { continue }
+ let nextLine = lines[nextIndex]
+ guard nextLine.hasPrefix("+++ ") else { continue }
+
+ let oldPath = String(line.dropFirst(4))
+ let newPath = String(nextLine.dropFirst(4))
+ let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
+ (newPath.hasPrefix("b/") || newPath == "/dev/null")
+
+ if looksLikeUnifiedDiff {
+ return index
+ }
+ }
+
+ return nil
+ }
+
+ private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
+ line == "-- " || line == "--"
+ }
+
+ private nonisolated static func normalizeLineEndings(in text: String) -> String {
+ text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ }
+
+ private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
+ let prefix = "\(headerName):"
+ let lines = body.components(separatedBy: .newlines)
+ for line in lines.prefix(12) {
+ if line.isEmpty {
+ break
+ }
+ if line.hasPrefix(prefix) {
+ return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
+ }
+ }
+ return nil
+ }
+
+ private static func executeGraphQLRequest<T: Decodable>(
+ client: SRHTClient,
+ query: String,
+ variables: [String: any Sendable]
+ ) async throws -> T {
+ guard let token = KeychainHelper.loadToken(), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: SRHTService.lists.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let encoder = JSONEncoder()
+ request.httpBody = try encoder.encode(
+ GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ )
+
+ let (data, _) = try await URLSession.shared.data(for: request)
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
+ #endif
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ if let errors = envelope.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ guard let payload = envelope.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response"))
+ )
+ }
+ return payload
+ }
+}
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
index b1db464..4380e22 100644
--- a/Hutch/Views/Repositories/DiffView.swift
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -9,14 +9,23 @@ struct DiffView: View {
let diff: String
var body: some View {
- let lines = diff.components(separatedBy: "\n")
+ let lines = normalizedDiff.components(separatedBy: "\n")
- LazyVStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
DiffLineView(line: line)
}
}
- .font(.caption.monospaced())
+ .font(.system(.caption, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+
+ private var normalizedDiff: String {
+ diff
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
}
}
@@ -27,7 +36,6 @@ private struct DiffLineView: View {
Text(line.isEmpty ? " " : line)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
- .padding(.vertical, 1)
.background(backgroundColor)
.foregroundStyle(foregroundColor)
.fontWeight(isHeader ? .semibold : .regular)
@@ -47,9 +55,9 @@ private struct DiffLineView: View {
switch kind {
case .added: .green.opacity(0.15)
case .removed: .red.opacity(0.15)
- case .hunk: .gray.opacity(0.12)
- case .fileHeader: .gray.opacity(0.08)
- case .meta: .gray.opacity(0.05)
+ case .hunk: .clear
+ case .fileHeader: .clear
+ case .meta: .clear
case .context: .clear
}
}
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
new file mode 100644
index 0000000..b202d03
--- /dev/null
+++ b/HutchTests/InboxViewModelTests.swift
@@ -0,0 +1,168 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+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)
+ }
+
+ @Test
+ func parsesMailtoDraft() {
+ let draft = ThreadViewModel.mailComposeDraft(
+ from: "mailto:[email protected][email protected]&subject=Re:%20PATCH&body=LGTM"
+ )
+
+ #expect(draft?.recipients == ["[email protected]"])
+ #expect(draft?.ccRecipients == ["[email protected]"])
+ #expect(draft?.subject == "Re: PATCH")
+ #expect(draft?.body == "LGTM")
+ }
+
+ @Test
+ func computesLocalUnreadStateFromLastViewedMarker() {
+ let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let threadID = "list#message"
+ let lastActivity = Date(timeIntervalSince1970: 2_000)
+
+ #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+ InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
+ #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+
+ InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
+ #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
+ }
+
+ @Test
+ func normalizesThreadSubjectsForDisplay() {
+ let summary = InboxThreadSummary(
+ rootEmailID: 1,
+ rootMessageID: "message",
+ threadRootEmailIDs: [1],
+ threadRootMessageIDs: ["message"],
+ listID: 2,
+ listRID: "list",
+ listName: "hut-devel",
+ listOwner: Entity(canonicalName: "~owner"),
+ subject: "Re: Fwd: [PATCH] test: add parser ",
+ latestSender: Entity(canonicalName: "~sender"),
+ lastActivityAt: Date(timeIntervalSince1970: 2_000),
+ messageCount: 3,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+
+ #expect(summary.displaySubject == "[PATCH] test: add parser")
+ #expect(summary.metadataLine.contains("~sender"))
+ #expect(summary.metadataLine.contains("2 replies"))
+ }
+
+ @Test
+ func keepsDistinctThreadsDistinctByRootMessageID() {
+ let baseList = InboxMailingListReference(
+ id: 1,
+ rid: "list",
+ name: "hut-devel",
+ owner: Entity(canonicalName: "~owner")
+ )
+
+ let first = 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] test",
+ latestSender: Entity(canonicalName: "~a"),
+ lastActivityAt: Date(timeIntervalSince1970: 100),
+ messageCount: 1,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+ let second = InboxThreadSummary(
+ rootEmailID: 11,
+ rootMessageID: "message-2",
+ threadRootEmailIDs: [11],
+ threadRootMessageIDs: ["message-2"],
+ listID: baseList.id,
+ listRID: baseList.rid,
+ listName: baseList.name,
+ listOwner: baseList.owner,
+ subject: "[PATCH] test",
+ latestSender: Entity(canonicalName: "~b"),
+ lastActivityAt: Date(timeIntervalSince1970: 200),
+ messageCount: 2,
+ repo: "hut",
+ containsPatch: true,
+ isUnread: true
+ )
+
+ #expect(first.id != second.id)
+ }
+
+ @Test
+ func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
+ let body = """
+ From: Christian Cleberg <[email protected]>
+
+ ---
+ test-patch.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100644 test-patch.txt
+
+ diff --git a/test-patch.txt b/test-patch.txt
+ new file mode 100644
+ index 0000000..c7b6eed
+ --- /dev/null
+ +++ b/test-patch.txt
+ @@ -0,0 +1 @@
+ +test Wed Mar 18 23:19:03 CDT 2026
+ --
+ 2.50.1 (Apple Git-155)
+ """
+
+ let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
+
+ #expect(segments.count == 3)
+
+ guard case let .plainText(leadingPlainText) = segments[0] else {
+ Issue.record("Expected first segment to be plain text")
+ return
+ }
+ #expect(leadingPlainText.contains("From: Christian Cleberg <[email protected]>"))
+ #expect(leadingPlainText.contains("---"))
+ #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
+ #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
+ #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
+
+ guard case let .diff(diff) = segments[1] else {
+ Issue.record("Expected second segment to be diff")
+ return
+ }
+ #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
+ #expect(diff.contains("--- /dev/null"))
+ #expect(diff.contains("+++ b/test-patch.txt"))
+ #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
+ #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
+
+ guard case let .plainText(trailingPlainText) = segments[2] else {
+ Issue.record("Expected third segment to be plain text")
+ return
+ }
+ #expect(trailingPlainText.contains("--"))
+ #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
+ }
+}