Commit 787436c54e

787436c54ea6621ae78f42712b4edfd145bc2edb

parent: 4c67bc570f

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 06:17 UTC

feat: add support for reading inbox messages (emails) and replying in-app

Layout: unified · split

Hutch/App/AppState.swift +14
@@ -7,6 +7,15 @@ import WebKit
77@MainActor
88final class AppState {
99
10 enum Tab: Hashable {
11 case home
12 case inbox
13 case repositories
14 case builds
15 case tickets
16 case settings
17 }
18
1019 enum AuthPhase {
1120 /// App just launched, checking for an existing token.
1221 case launching
@@ -25,6 +34,8 @@ final class AppState {
2534 authPhase == .authenticated && currentUser != nil
2635 }
2736
37 var selectedTab: Tab = .home
38
2839 // MARK: - Current user (populated after successful validation)
2940
3041 private(set) var currentUser: User?
@@ -94,6 +105,7 @@ final class AppState {
94105 await clearWebData()
95106 clearWebContentRenderCaches()
96107 authPhase = .unauthenticated
108 selectedTab = .home
97109 }
98110
99111 func resetAppData() async {
@@ -108,6 +120,7 @@ final class AppState {
108120 clearWebContentRenderCaches()
109121
110122 authPhase = .unauthenticated
123 selectedTab = .home
111124 }
112125
113126 // MARK: - Deep link resolution
@@ -208,6 +221,7 @@ final class AppState {
208221 client.responseCache.clear()
209222 currentUser = nil
210223 pendingDeepLink = nil
224 selectedTab = .home
211225 }
212226
213227 private func clearWebData() async {
Hutch/App/RootView.swift +22 −20
@@ -4,17 +4,8 @@ import SwiftUI
44/// full-screen sheet for token entry on first launch.
55struct RootView: View {
66 @Environment(AppState.self) private var appState
7
8 enum Tab: Hashable {
9 case home
10 case repositories
11 case builds
12 case tickets
13 case settings
14 }
15
16 @State private var selectedTab: Tab = .home
177 @State private var homePath = NavigationPath()
8 @State private var inboxPath = NavigationPath()
189 @State private var repoPath = NavigationPath()
1910 @State private var buildsPath = NavigationPath()
2011 @State private var ticketsPath = NavigationPath()
@@ -48,19 +39,29 @@ struct RootView: View {
4839 // MARK: - Tab View
4940
5041 private var tabContent: some View {
51 TabView(selection: $selectedTab) {
42 @Bindable var appState = appState
43
44 return TabView(selection: $appState.selectedTab) {
5245 NavigationStack(path: $homePath) {
5346 HomeView()
5447 }
55 .tag(Tab.home)
48 .tag(AppState.Tab.home)
5649 .tabItem {
5750 Label("Home", systemImage: "house")
5851 }
5952
53 NavigationStack(path: $inboxPath) {
54 InboxView()
55 }
56 .tag(AppState.Tab.inbox)
57 .tabItem {
58 Label("Inbox", systemImage: "tray")
59 }
60
6061 NavigationStack(path: $repoPath) {
6162 RepositoryListView()
6263 }
63 .tag(Tab.repositories)
64 .tag(AppState.Tab.repositories)
6465 .tabItem {
6566 Label("Repositories", systemImage: "book.closed")
6667 }
@@ -73,7 +74,7 @@ struct RootView: View {
7374 BuildDetailView(jobId: jobId)
7475 }
7576 }
76 .tag(Tab.builds)
77 .tag(AppState.Tab.builds)
7778 .tabItem {
7879 Label("Builds", systemImage: "hammer")
7980 }
@@ -85,13 +86,13 @@ struct RootView: View {
8586 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
8687 }
8788 }
88 .tag(Tab.tickets)
89 .tag(AppState.Tab.tickets)
8990 .tabItem {
9091 Label("Tickets", systemImage: "ticket")
9192 }
9293
9394 SettingsView()
94 .tag(Tab.settings)
95 .tag(AppState.Tab.settings)
9596 .tabItem {
9697 Label("Settings", systemImage: "gear")
9798 }
@@ -117,10 +118,11 @@ struct RootView: View {
117118 break
118119 case .unauthenticated:
119120 homePath = NavigationPath()
121 inboxPath = NavigationPath()
120122 repoPath = NavigationPath()
121123 buildsPath = NavigationPath()
122124 ticketsPath = NavigationPath()
123 selectedTab = .home
125 appState.selectedTab = .home
124126 isResolvingDeepLink = false
125127 case .authenticated:
126128 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
@@ -143,7 +145,7 @@ struct RootView: View {
143145 case .build(let jobId):
144146 // Reset the builds navigation and push the detail
145147 buildsPath = NavigationPath()
146 selectedTab = .builds
148 appState.selectedTab = .builds
147149 // Defer the push slightly so the tab switch takes effect
148150 Task { @MainActor in
149151 try? await Task.sleep(for: .milliseconds(100))
@@ -162,7 +164,7 @@ struct RootView: View {
162164 do {
163165 let summary = try await appState.resolveRepository(owner: owner, name: repo)
164166 repoPath = NavigationPath()
165 selectedTab = .repositories
167 appState.selectedTab = .repositories
166168 try? await Task.sleep(for: .milliseconds(100))
167169 repoPath.append(summary)
168170 } catch {
@@ -178,7 +180,7 @@ struct RootView: View {
178180 do {
179181 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
180182 ticketsPath = NavigationPath()
181 selectedTab = .tickets
183 appState.selectedTab = .tickets
182184 try? await Task.sleep(for: .milliseconds(100))
183185 ticketsPath.append(trackerSummary)
184186 try? await Task.sleep(for: .milliseconds(100))
Hutch/Models/Inbox.swift added +187
@@ -0,0 +1,187 @@
1import Foundation
2
3struct InboxThreadSummary: Identifiable, Hashable, Sendable {
4 let rootEmailID: Int
5 let rootMessageID: String
6 let threadRootEmailIDs: [Int]
7 let threadRootMessageIDs: [String]
8 let listID: Int
9 let listRID: String
10 let listName: String
11 let listOwner: Entity
12 let subject: String
13 let latestSender: Entity
14 let lastActivityAt: Date
15 let messageCount: Int?
16 let repo: String?
17 let containsPatch: Bool
18 let isUnread: Bool
19
20 var id: String {
21 threadGroupingKey
22 }
23
24 var listDisplayName: String {
25 "\(listOwner.canonicalName)/\(listName)"
26 }
27
28 var displaySubject: String {
29 Self.normalizedSubject(from: subject)
30 }
31
32 var metadataLine: String {
33 var parts = [latestSenderDisplayName]
34 if let messageCount, messageCount > 1 {
35 let replyCount = max(messageCount - 1, 1)
36 parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")")
37 }
38 parts.append(lastActivityAt.relativeDescription)
39 return parts.joined(separator: " • ")
40 }
41
42 var latestSenderDisplayName: String {
43 let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
44 if canonicalName.contains("@") {
45 return canonicalName
46 }
47 return canonicalName
48 }
49
50 var debugIdentifierSummary: String {
51 "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)"
52 }
53
54 var threadGroupingKey: String {
55 "\(listRID)#\(displaySubject.lowercased())"
56 }
57
58 private static func normalizedSubject(from subject: String) -> String {
59 let collapsedWhitespace = subject
60 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
61 .trimmingCharacters(in: .whitespacesAndNewlines)
62
63 let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"#
64 return collapsedWhitespace.replacingOccurrences(
65 of: pattern,
66 with: "",
67 options: [.regularExpression, .caseInsensitive]
68 )
69 }
70}
71
72struct InboxMessage: Identifiable, Hashable, Sendable {
73 let id: Int
74 let author: Entity
75 let date: Date
76 let subject: String
77 let body: String
78 let senderDisplayName: String
79 let senderEmailAddress: String?
80 let isPatch: Bool
81 let contentBlocks: [InboxMessageContentBlock]
82 let rawMessageURL: URL?
83}
84
85enum InboxMessageContentBlock: Hashable, Sendable {
86 case plainText(String)
87 case diff(String)
88}
89
90struct InboxThreadDetail: Sendable {
91 let id: String
92 let rootEmailID: Int
93 let rootMessageID: String
94 let subject: String
95 let author: Entity
96 let lastActivityAt: Date
97 let mailto: String?
98 let listID: Int
99 let listRID: String
100 let listName: String
101 let listOwner: Entity
102 let messageCount: Int?
103 let messages: [InboxMessage]
104
105 var listDisplayName: String {
106 "\(listOwner.canonicalName)/\(listName)"
107 }
108}
109
110extension InboxThreadDetail {
111 var replyRecipient: String {
112 "\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
113 }
114
115 var replySubject: String {
116 subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)"
117 }
118
119 var displaySubject: String {
120 InboxThreadSummary(
121 rootEmailID: rootEmailID,
122 rootMessageID: rootMessageID,
123 threadRootEmailIDs: [rootEmailID],
124 threadRootMessageIDs: [rootMessageID],
125 listID: listID,
126 listRID: listRID,
127 listName: listName,
128 listOwner: listOwner,
129 subject: subject,
130 latestSender: author,
131 lastActivityAt: lastActivityAt,
132 messageCount: messageCount,
133 repo: nil,
134 containsPatch: messages.contains(where: \.isPatch),
135 isUnread: false
136 ).displaySubject
137 }
138}
139
140struct MailComposeDraft: Sendable {
141 let recipients: [String]
142 let ccRecipients: [String]
143 let subject: String
144 let body: String
145
146 var id: String {
147 ([subject] + recipients + ccRecipients).joined(separator: "|")
148 }
149}
150
151extension MailComposeDraft: Identifiable {}
152
153struct InboxMailingListReference: Decodable, Sendable, Hashable {
154 let id: Int
155 let rid: String
156 let name: String
157 let owner: Entity
158}
159
160struct InboxPatchPreview: Decodable, Sendable, Hashable {
161 let subject: String?
162}
163
164enum InboxReadStateStore {
165 private static let key = "InboxThreadLastViewed"
166
167 static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? {
168 guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval],
169 let timestamp = dictionary[threadID] else {
170 return nil
171 }
172 return Date(timeIntervalSince1970: timestamp)
173 }
174
175 static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) {
176 var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:]
177 dictionary[threadID] = date.timeIntervalSince1970
178 defaults.set(dictionary, forKey: key)
179 }
180
181 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
182 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
183 return true
184 }
185 return lastActivityAt > lastViewedAt
186 }
187}
Hutch/Views/Home/HomeView.swift +23 −2
@@ -115,8 +115,8 @@ struct HomeView: View {
115115 }
116116 }
117117 } header: {
118 HomeSectionHeader("Recent Builds") {
119 BuildListView()
118 HomeSectionActionHeader("Recent Builds") {
119 appState.selectedTab = .builds
120120 }
121121 }
122122 }
@@ -243,6 +243,27 @@ private struct HomeSectionHeader<Destination: View>: View {
243243 }
244244}
245245
246private struct HomeSectionActionHeader: View {
247 let title: String
248 let action: () -> Void
249
250 init(_ title: String, action: @escaping () -> Void) {
251 self.title = title
252 self.action = action
253 }
254
255 var body: some View {
256 HStack {
257 Text(title)
258 Spacer()
259 Button("See All", action: action)
260 .font(.caption.weight(.medium))
261 .buttonStyle(.plain)
262 }
263 .textCase(nil)
264 }
265}
266
246267private struct HomeAssignedTicketsListView: View {
247268 let viewModel: HomeViewModel
248269
Hutch/Views/Inbox/InboxView.swift added +107
@@ -0,0 +1,107 @@
1import SwiftUI
2
3struct InboxView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: InboxViewModel?
6
7 var body: some View {
8 Group {
9 if let viewModel {
10 listContent(viewModel)
11 } else {
12 SRHTLoadingStateView(message: "Loading inbox…")
13 }
14 }
15 .navigationTitle("Inbox")
16 .task {
17 if viewModel == nil {
18 let vm = InboxViewModel(client: appState.client)
19 viewModel = vm
20 await vm.loadThreads()
21 }
22 }
23 }
24
25 @ViewBuilder
26 private func listContent(_ viewModel: InboxViewModel) -> some View {
27 @Bindable var vm = viewModel
28
29 List {
30 ForEach(viewModel.threads) { thread in
31 NavigationLink(value: thread) {
32 InboxThreadRow(thread: thread)
33 }
34 }
35 }
36 .listStyle(.plain)
37 .overlay {
38 if viewModel.isLoading, viewModel.threads.isEmpty {
39 SRHTLoadingStateView(message: "Loading inbox…")
40 } else if let error = viewModel.error, viewModel.threads.isEmpty {
41 SRHTErrorStateView(
42 title: "Couldn't Load Threads",
43 message: error,
44 retryAction: { await viewModel.loadThreads() }
45 )
46 } else if viewModel.threads.isEmpty, viewModel.error == nil {
47 ContentUnavailableView(
48 "No Threads",
49 systemImage: "tray",
50 description: Text("Patch threads will appear here.")
51 )
52 }
53 }
54 .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
55 await viewModel.loadThreads()
56 }
57 .srhtErrorBanner(error: $vm.error)
58 .refreshable {
59 await viewModel.loadThreads()
60 }
61 .navigationDestination(for: InboxThreadSummary.self) { thread in
62 ThreadDetailView(thread: thread) {
63 viewModel.markThreadRead(thread)
64 }
65 }
66 }
67}
68
69private struct InboxThreadRow: View {
70 let thread: InboxThreadSummary
71
72 var body: some View {
73 HStack(alignment: .top, spacing: 12) {
74 Circle()
75 .fill(thread.isUnread ? .blue : .clear)
76 .frame(width: 8, height: 8)
77 .padding(.top, 6)
78
79 VStack(alignment: .leading, spacing: 4) {
80 Text(thread.displaySubject)
81 .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
82 .lineLimit(2)
83
84 HStack(spacing: 8) {
85 if thread.containsPatch {
86 Image(systemName: "arrow.triangle.branch")
87 .font(.caption)
88 .foregroundStyle(.secondary)
89 }
90
91 Text(thread.metadataLine)
92 .font(.caption)
93 .foregroundStyle(.secondary)
94 .lineLimit(1)
95 }
96 }
97
98 Spacer(minLength: 8)
99
100 Text(thread.lastActivityAt.relativeDescription)
101 .font(.caption)
102 .foregroundStyle(.tertiary.opacity(0.7))
103 .lineLimit(1)
104 }
105 .padding(.vertical, 2)
106 }
107}
Hutch/Views/Inbox/InboxViewModel.swift added +335
@@ -0,0 +1,335 @@
1import Foundation
2import os
3
4private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
5
6private struct InboxSubscriptionsResponse: Decodable, Sendable {
7 let subscriptions: InboxSubscriptionPage
8}
9
10private struct InboxSubscriptionPage: Decodable, Sendable {
11 let results: [InboxActivitySubscription]
12 let cursor: String?
13}
14
15private struct InboxActivitySubscription: Decodable, Sendable {
16 let id: Int
17 let created: Date
18 let list: InboxMailingListReference?
19
20 enum CodingKeys: String, CodingKey {
21 case id
22 case created
23 case list
24 }
25}
26
27private struct InboxListThreadsResponse: Decodable, Sendable {
28 let list: InboxMailingListThreads
29}
30
31private struct InboxMailingListThreads: Decodable, Sendable {
32 let threads: InboxThreadPage
33}
34
35private struct InboxThreadPage: Decodable, Sendable {
36 let results: [InboxThreadPayload]
37 let cursor: String?
38}
39
40private struct InboxThreadPayload: Decodable, Sendable {
41 let created: Date
42 let updated: Date
43 let subject: String
44 let replies: Int
45 let sender: Entity
46 let root: InboxEmailPreview
47}
48
49private struct InboxEmailPreview: Decodable, Sendable {
50 let id: Int
51 let subject: String
52 let date: Date?
53 let received: Date
54 let messageID: String
55 let body: String
56 let patch: InboxPatchPreview?
57}
58
59@Observable
60@MainActor
61final class InboxViewModel {
62 private(set) var threads: [InboxThreadSummary] = []
63 private(set) var isLoading = false
64 var error: String?
65
66 private let client: SRHTClient
67 private let listThreadFetchLimit = 10
68 private let listFetchConcurrencyLimit = 4
69
70 private static let subscriptionsQuery = """
71 query inboxSubscriptions($cursor: Cursor) {
72 subscriptions(cursor: $cursor) {
73 results {
74 ... on MailingListSubscription {
75 id
76 created
77 list {
78 id
79 rid
80 name
81 owner { canonicalName }
82 }
83 }
84 }
85 cursor
86 }
87 }
88 """
89
90 private static let listThreadsQuery = """
91 query inboxListThreads($rid: ID!, $cursor: Cursor) {
92 list(rid: $rid) {
93 threads(cursor: $cursor) {
94 results {
95 created
96 updated
97 subject
98 replies
99 sender { canonicalName }
100 root {
101 id
102 subject
103 date
104 received
105 messageID
106 body
107 patch { subject }
108 }
109 }
110 cursor
111 }
112 }
113 }
114 """
115
116 init(client: SRHTClient) {
117 self.client = client
118 }
119
120 func loadThreads() async {
121 guard !isLoading else { return }
122 isLoading = true
123 error = nil
124 defer { isLoading = false }
125
126 do {
127 let subscriptions = try await fetchSubscriptions()
128 let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
129 let fetchedThreads = try await fetchThreads(for: mailingLists)
130 threads = fetchedThreads.sorted { lhs, rhs in
131 if lhs.lastActivityAt == rhs.lastActivityAt {
132 return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
133 }
134 return lhs.lastActivityAt > rhs.lastActivityAt
135 }
136 } catch {
137 threads = []
138 self.error = error.localizedDescription
139 }
140 }
141
142 func markThreadRead(_ thread: InboxThreadSummary) {
143 let viewedAt = max(Date(), thread.lastActivityAt)
144 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
145 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
146 let current = threads[index]
147 threads[index] = InboxThreadSummary(
148 rootEmailID: current.rootEmailID,
149 rootMessageID: current.rootMessageID,
150 threadRootEmailIDs: current.threadRootEmailIDs,
151 threadRootMessageIDs: current.threadRootMessageIDs,
152 listID: current.listID,
153 listRID: current.listRID,
154 listName: current.listName,
155 listOwner: current.listOwner,
156 subject: current.subject,
157 latestSender: current.latestSender,
158 lastActivityAt: current.lastActivityAt,
159 messageCount: current.messageCount,
160 repo: current.repo,
161 containsPatch: current.containsPatch,
162 isUnread: false
163 )
164 }
165
166 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
167 var subscriptions: [InboxActivitySubscription] = []
168 var cursor: String?
169
170 while true {
171 var variables: [String: any Sendable] = [:]
172 if let cursor {
173 variables["cursor"] = cursor
174 }
175
176 let response = try await client.execute(
177 service: .lists,
178 query: Self.subscriptionsQuery,
179 variables: variables.isEmpty ? nil : variables,
180 responseType: InboxSubscriptionsResponse.self
181 )
182
183 subscriptions.append(contentsOf: response.subscriptions.results)
184 guard let nextCursor = response.subscriptions.cursor else {
185 break
186 }
187 cursor = nextCursor
188 }
189
190 return subscriptions
191 }
192
193 private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
194 guard !mailingLists.isEmpty else { return [] }
195
196 var summaries: [InboxThreadSummary] = []
197 var startIndex = mailingLists.startIndex
198 var failureMessages: [String] = []
199
200 while startIndex < mailingLists.endIndex {
201 let endIndex = mailingLists.index(
202 startIndex,
203 offsetBy: listFetchConcurrencyLimit,
204 limitedBy: mailingLists.endIndex
205 ) ?? mailingLists.endIndex
206 let batch = Array(mailingLists[startIndex..<endIndex])
207
208 let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
209 for mailingList in batch {
210 group.addTask {
211 do {
212 return (try await self.fetchThreads(for: mailingList), nil)
213 } catch {
214 return ([], error.localizedDescription)
215 }
216 }
217 }
218
219 var batchSummaries: [InboxThreadSummary] = []
220 var batchFailures: [String] = []
221 for await result in group {
222 batchSummaries.append(contentsOf: result.0)
223 if let failure = result.1 {
224 batchFailures.append(failure)
225 }
226 }
227 return (batchSummaries, batchFailures)
228 }
229
230 summaries.append(contentsOf: batchResult.0)
231 failureMessages.append(contentsOf: batchResult.1)
232 startIndex = endIndex
233 }
234
235 if summaries.isEmpty, let firstFailure = failureMessages.first {
236 throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
237 }
238
239 return deduplicateThreads(summaries)
240 }
241
242 private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
243 let response = try await client.execute(
244 service: .lists,
245 query: Self.listThreadsQuery,
246 variables: ["rid": mailingList.rid],
247 responseType: InboxListThreadsResponse.self
248 )
249
250 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
251 let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
252 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())"
253 inboxListLogger.debug(
254 "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)"
255 )
256 return InboxThreadSummary(
257 rootEmailID: thread.root.id,
258 rootMessageID: thread.root.messageID,
259 threadRootEmailIDs: [thread.root.id],
260 threadRootMessageIDs: [thread.root.messageID],
261 listID: mailingList.id,
262 listRID: mailingList.rid,
263 listName: mailingList.name,
264 listOwner: mailingList.owner,
265 subject: thread.subject,
266 latestSender: thread.sender,
267 lastActivityAt: thread.updated,
268 messageCount: thread.replies + 1,
269 repo: Self.deriveRepositoryName(from: mailingList.name),
270 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
271 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
272 )
273 }
274 }
275
276 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
277 var grouped: [String: InboxThreadSummary] = [:]
278
279 for thread in threads {
280 guard let existing = grouped[thread.threadGroupingKey] else {
281 grouped[thread.threadGroupingKey] = thread
282 continue
283 }
284
285 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
286 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
287 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
288 let mergedMessageCount = max(
289 existing.messageCount ?? existing.threadRootMessageIDs.count,
290 thread.messageCount ?? thread.threadRootMessageIDs.count,
291 mergedRootMessageIDs.count
292 )
293
294 grouped[thread.threadGroupingKey] = InboxThreadSummary(
295 rootEmailID: latest.rootEmailID,
296 rootMessageID: latest.rootMessageID,
297 threadRootEmailIDs: mergedRootEmailIDs,
298 threadRootMessageIDs: mergedRootMessageIDs,
299 listID: latest.listID,
300 listRID: latest.listRID,
301 listName: latest.listName,
302 listOwner: latest.listOwner,
303 subject: latest.subject,
304 latestSender: latest.latestSender,
305 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
306 messageCount: mergedMessageCount,
307 repo: latest.repo ?? existing.repo,
308 containsPatch: latest.containsPatch || existing.containsPatch,
309 isUnread: latest.isUnread || existing.isUnread
310 )
311 }
312
313 return grouped.values.sorted { lhs, rhs in
314 if lhs.lastActivityAt == rhs.lastActivityAt {
315 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
316 }
317 return lhs.lastActivityAt > rhs.lastActivityAt
318 }
319 }
320
321 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
322 var seen = Set<String>()
323 return mailingLists.filter { mailingList in
324 seen.insert(mailingList.rid).inserted
325 }
326 }
327
328 nonisolated static func deriveRepositoryName(from listName: String) -> String? {
329 let separators = ["-devel", "-patches", "-dev", ".patches"]
330 for separator in separators where listName.hasSuffix(separator) {
331 return String(listName.dropLast(separator.count))
332 }
333 return nil
334 }
335}
Hutch/Views/Inbox/ThreadDetailView.swift added +324
@@ -0,0 +1,324 @@
1import MessageUI
2import os
3import SwiftUI
4import UIKit
5
6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7
8struct ThreadDetailView: View {
9 let thread: InboxThreadSummary
10 let onViewed: () -> Void
11
12 @Environment(AppState.self) private var appState
13 @State private var viewModel: ThreadViewModel?
14 @State private var replySuccessMessage: String?
15
16 var body: some View {
17 Group {
18 if let viewModel {
19 content(viewModel)
20 } else {
21 SRHTLoadingStateView(message: "Loading thread…")
22 }
23 }
24 .navigationTitle("Thread")
25 .navigationBarTitleDisplayMode(.inline)
26 .task {
27 if viewModel == nil {
28 onViewed()
29 let vm = ThreadViewModel(summary: thread, client: appState.client)
30 viewModel = vm
31 await vm.loadThread()
32 }
33 }
34 .sheet(item: Binding(
35 get: { viewModel?.composeDraft },
36 set: { _ in viewModel?.dismissReply() }
37 )) { draft in
38 MailComposeView(draft: draft) { result in
39 switch result {
40 case .failed(let message):
41 inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)")
42 viewModel?.error = message
43 case .cancelled:
44 inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)")
45 case .saved:
46 inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)")
47 case .sent:
48 inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
49 replySuccessMessage = "Reply handed off to Mail."
50 Task {
51 await viewModel?.loadThread()
52 }
53 }
54 }
55 }
56 .overlay(alignment: .top) {
57 if let replySuccessMessage {
58 Text(replySuccessMessage)
59 .font(.caption.weight(.medium))
60 .padding(.horizontal, 12)
61 .padding(.vertical, 8)
62 .background(.thinMaterial, in: Capsule())
63 .padding(.top, 8)
64 .transition(.move(edge: .top).combined(with: .opacity))
65 }
66 }
67 .animation(.easeInOut(duration: 0.2), value: replySuccessMessage)
68 .onChange(of: replySuccessMessage) { _, message in
69 guard message != nil else { return }
70 Task { @MainActor in
71 try? await Task.sleep(for: .seconds(2))
72 if self.replySuccessMessage == message {
73 self.replySuccessMessage = nil
74 }
75 }
76 }
77 }
78
79 @ViewBuilder
80 private func content(_ viewModel: ThreadViewModel) -> some View {
81 @Bindable var vm = viewModel
82
83 List {
84 if let thread = viewModel.thread {
85 Section {
86 VStack(alignment: .leading, spacing: 6) {
87 Text(thread.displaySubject)
88 .font(.headline)
89 Text(headerMetadata(thread))
90 .font(.caption)
91 .foregroundStyle(.secondary)
92 }
93 .padding(.vertical, 4)
94 }
95
96 ForEach(thread.messages) { message in
97 InboxMessageRow(message: message)
98 }
99 }
100 }
101 .listStyle(.plain)
102 .toolbar {
103 ToolbarItem(placement: .topBarTrailing) {
104 Button("Reply") {
105 viewModel.prepareReply()
106 }
107 }
108 }
109 .overlay {
110 if viewModel.isLoading, viewModel.thread == nil {
111 SRHTLoadingStateView(message: "Loading thread…")
112 } else if let error = viewModel.error, viewModel.thread == nil {
113 SRHTErrorStateView(
114 title: "Couldn't Load Thread",
115 message: error,
116 retryAction: { await viewModel.loadThread() }
117 )
118 }
119 }
120 .srhtErrorBanner(error: $vm.error)
121 .refreshable {
122 await viewModel.loadThread()
123 }
124 }
125
126 private func headerMetadata(_ thread: InboxThreadDetail) -> String {
127 var parts = [thread.listDisplayName]
128 if let messageCount = thread.messageCount, messageCount > 1 {
129 parts.append("\(messageCount) messages")
130 }
131 parts.append(thread.lastActivityAt.relativeDescription)
132 return parts.joined(separator: " • ")
133 }
134}
135
136private struct InboxMessageRow: View {
137 let message: InboxMessage
138
139 var body: some View {
140 VStack(alignment: .leading, spacing: 10) {
141 HStack(alignment: .top, spacing: 12) {
142 VStack(alignment: .leading, spacing: 2) {
143 Text(senderLine)
144 .font(.subheadline.weight(.medium))
145 .lineLimit(2)
146 Text(message.date.formatted(date: .abbreviated, time: .shortened))
147 .font(.caption)
148 .foregroundStyle(.secondary)
149 }
150
151 Spacer()
152
153 if message.isPatch {
154 Text("Patch")
155 .font(.caption2.weight(.medium))
156 .foregroundStyle(.secondary)
157 }
158 }
159
160 ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in
161 switch block {
162 case .plainText(let text):
163 Text(text)
164 .font(.body)
165 .textSelection(.enabled)
166 .frame(maxWidth: .infinity, alignment: .leading)
167 .fixedSize(horizontal: false, vertical: true)
168 case .diff(let diff):
169 ScrollView(.horizontal) {
170 DiffView(diff: diff)
171 .textSelection(.enabled)
172 .frame(maxWidth: .infinity, alignment: .leading)
173 }
174 }
175 }
176 }
177 .padding(.vertical, 6)
178 .listRowSeparator(.visible)
179 }
180
181 private var senderLine: String {
182 if let email = message.senderEmailAddress,
183 email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame {
184 return "\(message.senderDisplayName) <\(email)>"
185 }
186 return message.senderDisplayName
187 }
188}
189
190private struct MailComposeView: UIViewControllerRepresentable {
191 let draft: MailComposeDraft
192 let onComplete: (Result) -> Void
193
194 enum Result {
195 case cancelled
196 case saved
197 case sent
198 case failed(String)
199 }
200
201 func makeCoordinator() -> Coordinator {
202 Coordinator(onComplete: onComplete)
203 }
204
205 func makeUIViewController(context: Context) -> UIViewController {
206 guard MFMailComposeViewController.canSendMail() else {
207 let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: {
208 context.coordinator.onComplete(.failed("Mail is not configured on this device."))
209 }))
210 DispatchQueue.main.async {
211 UIImpactFeedbackGenerator(style: .light).impactOccurred()
212 }
213 return controller
214 }
215
216 let controller = MFMailComposeViewController()
217 controller.mailComposeDelegate = context.coordinator
218 controller.setToRecipients(draft.recipients)
219 if !draft.ccRecipients.isEmpty {
220 controller.setCcRecipients(draft.ccRecipients)
221 }
222 if !draft.subject.isEmpty {
223 controller.setSubject(draft.subject)
224 }
225 if !draft.body.isEmpty {
226 controller.setMessageBody(draft.body, isHTML: false)
227 }
228 return controller
229 }
230
231 func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
232
233 final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
234 let onComplete: (Result) -> Void
235
236 init(onComplete: @escaping (Result) -> Void) {
237 self.onComplete = onComplete
238 }
239
240 func mailComposeController(
241 _ controller: MFMailComposeViewController,
242 didFinishWith result: MFMailComposeResult,
243 error: Error?
244 ) {
245 if error != nil {
246 let message = error?.localizedDescription ?? "The reply could not be sent."
247 presentFailureAlert(on: controller, message: message)
248 onComplete(.failed(message))
249 return
250 }
251 switch result {
252 case .cancelled:
253 controller.dismiss(animated: true)
254 onComplete(.cancelled)
255 case .saved:
256 controller.dismiss(animated: true)
257 onComplete(.saved)
258 case .sent:
259 controller.dismiss(animated: true)
260 onComplete(.sent)
261 case .failed:
262 let message = "Mail could not send the reply from the configured iOS Mail account."
263 presentFailureAlert(on: controller, message: message)
264 onComplete(.failed(message))
265 @unknown default:
266 let message = "Mail returned an unknown result while sending the reply."
267 presentFailureAlert(on: controller, message: message)
268 onComplete(.failed(message))
269 }
270 }
271
272 private func presentFailureAlert(on controller: UIViewController, message: String) {
273 guard controller.presentedViewController == nil else { return }
274 let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert)
275 alert.addAction(UIAlertAction(title: "OK", style: .default))
276 controller.present(alert, animated: true)
277 }
278 }
279}
280
281private final class MailUnavailableViewController: UIViewController {
282 private let onDismiss: () -> Void
283
284 init(onDismiss: @escaping () -> Void) {
285 self.onDismiss = onDismiss
286 super.init(nibName: nil, bundle: nil)
287 }
288
289 @available(*, unavailable)
290 required init?(coder: NSCoder) {
291 fatalError("init(coder:) has not been implemented")
292 }
293
294 override func viewDidLoad() {
295 super.viewDidLoad()
296 view.backgroundColor = .systemBackground
297 navigationItem.title = "Reply"
298 navigationItem.rightBarButtonItem = UIBarButtonItem(
299 barButtonSystemItem: .done,
300 target: self,
301 action: #selector(dismissSelf)
302 )
303
304 let label = UILabel()
305 label.translatesAutoresizingMaskIntoConstraints = false
306 label.text = "Mail is not configured on this device."
307 label.textAlignment = .center
308 label.numberOfLines = 0
309 label.textColor = .secondaryLabel
310
311 view.addSubview(label)
312 NSLayoutConstraint.activate([
313 label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
314 label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
315 label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
316 ])
317 }
318
319 @objc
320 private func dismissSelf() {
321 dismiss(animated: true)
322 onDismiss()
323 }
324}
Hutch/Views/Inbox/ThreadViewModel.swift added +702
@@ -0,0 +1,702 @@
1import Foundation
2import os
3
4private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
5
6private struct InboxThreadDetailResponse: Decodable, Sendable {
7 let list: InboxThreadDetailList?
8}
9
10private struct InboxThreadDetailList: Decodable, Sendable {
11 let threads: InboxThreadPayloadPage?
12}
13
14private struct InboxThreadLookupResponse: Decodable, Sendable {
15 let list: InboxThreadLookupList?
16}
17
18private struct InboxThreadLookupList: Decodable, Sendable {
19 let message: InboxThreadLookupMessage?
20}
21
22private struct InboxThreadLookupMessage: Decodable, Sendable {
23 let thread: InboxThreadPayloadDetail?
24}
25
26private struct InboxThreadPayloadDetail: Decodable, Sendable {
27 let subject: String?
28 let updated: Date?
29 let replies: Int?
30 let sender: Entity?
31 let list: InboxMailingListReference?
32 let root: InboxThreadMessagePayload?
33 let descendants: InboxThreadMessagesPage?
34}
35
36private struct InboxThreadPayloadPage: Decodable, Sendable {
37 let results: [InboxThreadPayloadDetail]
38 let cursor: String?
39}
40
41private struct InboxThreadMessagesPage: Decodable, Sendable {
42 let results: [InboxThreadMessagePayload]?
43 let cursor: String?
44}
45
46private struct InboxThreadMessagePayload: Decodable, Sendable {
47 let id: Int?
48 let sender: Entity?
49 let received: Date?
50 let date: Date?
51 let subject: String?
52 let messageID: String?
53 let body: String?
54 let rawMessage: URL?
55 let patch: InboxPatchPreview?
56}
57
58@Observable
59@MainActor
60final class ThreadViewModel {
61 private(set) var thread: InboxThreadDetail?
62 private(set) var isLoading = false
63 var error: String?
64 var composeDraft: MailComposeDraft?
65
66 private let summary: InboxThreadSummary
67 private let client: SRHTClient
68
69 private static let threadDetailQuery = """
70 query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
71 list(rid: $rid) {
72 threads(cursor: $cursor) {
73 results {
74 subject
75 updated
76 replies
77 sender { canonicalName }
78 list {
79 id
80 rid
81 name
82 owner { canonicalName }
83 }
84 root {
85 id
86 sender { canonicalName }
87 received
88 date
89 subject
90 messageID
91 body
92 rawMessage
93 patch { subject }
94 }
95 descendants(cursor: $descCursor) {
96 results {
97 id
98 sender { canonicalName }
99 received
100 date
101 subject
102 messageID
103 body
104 rawMessage
105 patch { subject }
106 }
107 cursor
108 }
109 }
110 cursor
111 }
112 }
113 }
114 """
115
116 private static let threadByMessageIDQuery = """
117 query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
118 list(rid: $rid) {
119 message(messageID: $messageID) {
120 thread {
121 subject
122 updated
123 replies
124 sender { canonicalName }
125 list {
126 id
127 rid
128 name
129 owner { canonicalName }
130 }
131 root {
132 id
133 sender { canonicalName }
134 received
135 date
136 subject
137 messageID
138 body
139 rawMessage
140 patch { subject }
141 }
142 descendants(cursor: $descCursor) {
143 results {
144 id
145 sender { canonicalName }
146 received
147 date
148 subject
149 messageID
150 body
151 rawMessage
152 patch { subject }
153 }
154 cursor
155 }
156 }
157 }
158 }
159 }
160 """
161
162 init(summary: InboxThreadSummary, client: SRHTClient) {
163 self.summary = summary
164 self.client = client
165 }
166
167 func loadThread() async {
168 guard !isLoading else { return }
169 isLoading = true
170 error = nil
171 defer { isLoading = false }
172
173 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
174
175 do {
176 let threadPayloads = try await fetchThreadPayloads()
177
178 guard !threadPayloads.isEmpty else {
179 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
180 }
181
182 let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
183 id: summary.listID,
184 rid: summary.listRID,
185 name: summary.listName,
186 owner: summary.listOwner
187 )
188 var messagesByID: [Int: InboxMessage] = [:]
189
190 for payload in threadPayloads {
191 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
192 continue
193 }
194 messagesByID[rootMessage.id] = rootMessage
195
196 let descendantMessages = try await fetchAllDescendantMessages(
197 initialPayload: payload,
198 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
199 )
200 for message in descendantMessages {
201 messagesByID[message.id] = message
202 }
203 }
204
205 let messages = messagesByID.values.sorted { $0.date < $1.date }
206 guard !messages.isEmpty else {
207 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
208 }
209
210 let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
211 thread = InboxThreadDetail(
212 id: summary.id,
213 rootEmailID: summary.rootEmailID,
214 rootMessageID: summary.rootMessageID,
215 subject: latestPayload.subject ?? summary.subject,
216 author: latestPayload.sender ?? summary.latestSender,
217 lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
218 mailto: nil,
219 listID: listReference.id,
220 listRID: listReference.rid,
221 listName: listReference.name,
222 listOwner: listReference.owner,
223 messageCount: max(messages.count, summary.messageCount ?? 0),
224 messages: messages
225 )
226 } catch {
227 thread = nil
228 self.error = error.localizedDescription
229 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
230 }
231 }
232
233 private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
234 var payloads: [InboxThreadPayloadDetail] = []
235 var seenRoots = Set<String>()
236
237 for rootMessageID in summary.threadRootMessageIDs {
238 guard !seenRoots.contains(rootMessageID) else { continue }
239 seenRoots.insert(rootMessageID)
240 if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
241 payloads.append(payload)
242 }
243 }
244
245 if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
246 payloads.append(fallback)
247 }
248
249 return payloads
250 }
251
252 private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
253 if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
254 return messageMatchedThread
255 }
256 return try await scanThreadPages(targetRootMessageID: rootMessageID)
257 }
258
259 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
260 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
261 inboxLogger.debug(
262 "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)"
263 )
264
265 var lastLookupError: Error?
266
267 for messageID in candidateMessageIDs {
268 inboxLogger.debug(
269 "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
270 )
271
272 do {
273 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
274 client: client,
275 query: Self.threadByMessageIDQuery,
276 variables: [
277 "rid": self.summary.listRID,
278 "messageID": messageID,
279 "descCursor": nil as String?
280 ]
281 )
282
283 if let thread = response.list?.message?.thread {
284 return thread
285 }
286 } catch let error as SRHTError {
287 switch error {
288 case .graphQLErrors(let errors):
289 let combinedMessage = errors.map(\.message).joined(separator: " | ")
290 inboxLogger.error(
291 "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
292 )
293 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
294 lastLookupError = error
295 continue
296 }
297 throw error
298 default:
299 throw error
300 }
301 }
302 }
303
304 if let lastLookupError {
305 inboxLogger.debug(
306 "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
307 )
308 }
309 return nil
310 }
311
312 private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
313 var threadCursor: String?
314
315 while true {
316 var variables: [String: any Sendable] = ["rid": summary.listRID]
317 if let threadCursor {
318 variables["cursor"] = threadCursor
319 }
320
321 let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
322 client: client,
323 query: Self.threadDetailQuery,
324 variables: {
325 var variables = variables
326 variables["descCursor"] = nil as String?
327 return variables
328 }()
329 )
330
331 guard let threadPage = response.list?.threads else {
332 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
333 }
334
335 let candidates = threadPage.results.map { payload in
336 "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
337 }.joined(separator: " | ")
338 inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
339
340 if let matchedThread = threadPage.results.first(where: {
341 $0.root?.messageID == targetRootMessageID ||
342 $0.root?.id == summary.rootEmailID ||
343 $0.root?.subject == summary.subject
344 }) {
345 return matchedThread
346 }
347
348 guard let nextCursor = threadPage.cursor else {
349 return nil
350 }
351 threadCursor = nextCursor
352 }
353 }
354
355 private func fetchAllDescendantMessages(
356 initialPayload: InboxThreadPayloadDetail,
357 candidateMessageIDs: [String]
358 ) async throws -> [InboxMessage] {
359 var messagesByID: [Int: InboxMessage] = [:]
360
361 for payload in initialPayload.descendants?.results ?? [] {
362 if let message = Self.message(from: payload, fallbackID: nil) {
363 messagesByID[message.id] = message
364 }
365 }
366
367 var descendantCursor = initialPayload.descendants?.cursor
368 while let currentCursor = descendantCursor {
369 guard let page = try await fetchDescendantPage(
370 cursor: currentCursor,
371 candidateMessageIDs: candidateMessageIDs
372 ) else {
373 break
374 }
375
376 for payload in page.results ?? [] {
377 if let message = Self.message(from: payload, fallbackID: nil) {
378 messagesByID[message.id] = message
379 }
380 }
381 descendantCursor = page.cursor
382 }
383
384 return messagesByID.values.sorted { $0.date < $1.date }
385 }
386
387 private func fetchDescendantPage(
388 cursor: String,
389 candidateMessageIDs: [String]
390 ) async throws -> InboxThreadMessagesPage? {
391 for messageID in candidateMessageIDs {
392 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
393 client: client,
394 query: Self.threadByMessageIDQuery,
395 variables: [
396 "rid": summary.listRID,
397 "messageID": messageID,
398 "descCursor": cursor
399 ]
400 )
401
402 if let descendants = response.list?.message?.thread?.descendants {
403 return descendants
404 }
405 }
406
407 return nil
408 }
409
410 func prepareReply() {
411 guard let thread else {
412 error = "This thread is not ready to reply to yet."
413 return
414 }
415 inboxLogger.debug(
416 "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"
417 )
418 composeDraft = MailComposeDraft(
419 recipients: [thread.replyRecipient],
420 ccRecipients: [],
421 subject: thread.replySubject,
422 body: ""
423 )
424 }
425
426 func dismissReply() {
427 composeDraft = nil
428 }
429
430 private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
431 guard let payload else { return nil }
432 guard let id = payload.id ?? fallbackID,
433 let author = payload.sender,
434 let date = payload.date ?? payload.received,
435 let subject = payload.subject,
436 let body = payload.body else {
437 return nil
438 }
439
440 let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
441 let displayBody = sanitizedDisplayBody(from: body)
442 let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
443
444 return InboxMessage(
445 id: id,
446 author: author,
447 date: date,
448 subject: subject,
449 body: body,
450 senderDisplayName: normalizedIdentity.displayName,
451 senderEmailAddress: normalizedIdentity.emailAddress,
452 isPatch: payload.patch != nil,
453 contentBlocks: contentBlocks,
454 rawMessageURL: payload.rawMessage
455 )
456 }
457
458 nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
459 guard let components = URLComponents(string: mailto),
460 components.scheme?.lowercased() == "mailto" else {
461 return nil
462 }
463
464 let recipients = components.path
465 .split(separator: ",")
466 .map { String($0) }
467 .filter { !$0.isEmpty }
468 let queryItems = components.queryItems ?? []
469 let ccRecipients = queryItems
470 .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
471 .value?
472 .split(separator: ",")
473 .map(String.init) ?? []
474 let subject = queryItems
475 .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
476 .value ?? ""
477 let body = queryItems
478 .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
479 .value ?? ""
480
481 return MailComposeDraft(
482 recipients: recipients,
483 ccRecipients: ccRecipients,
484 subject: subject,
485 body: body
486 )
487 }
488
489 private static func messageIDCandidates(from messageID: String) -> [String] {
490 let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
491 guard !trimmedMessageID.isEmpty else { return [] }
492
493 if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
494 return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
495 }
496
497 return [trimmedMessageID, "<\(trimmedMessageID)>"]
498 }
499
500 private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
501 guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
502 return fallbackSenderIdentity(from: fallbackAuthor)
503 }
504
505 let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
506 if let start = trimmedFromLine.lastIndex(of: "<"),
507 let end = trimmedFromLine.lastIndex(of: ">"),
508 start < end {
509 let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
510 let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
511 if !name.isEmpty {
512 return (name, email.isEmpty ? nil : email)
513 }
514 return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
515 }
516
517 if trimmedFromLine.contains("@") {
518 return (trimmedFromLine, trimmedFromLine)
519 }
520
521 return (trimmedFromLine, nil)
522 }
523
524 private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
525 let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
526 if canonicalName.contains("@") {
527 return (canonicalName, canonicalName)
528 }
529 if canonicalName.hasPrefix("~") {
530 return (String(canonicalName.dropFirst()), nil)
531 }
532 return (canonicalName, nil)
533 }
534
535 private static func sanitizedDisplayBody(from body: String) -> String {
536 let lines = body.components(separatedBy: .newlines)
537 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
538 var headerCount = 0
539 var blankLineIndex: Int?
540
541 for (index, line) in lines.prefix(12).enumerated() {
542 if line.isEmpty {
543 blankLineIndex = index
544 break
545 }
546 if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
547 headerCount += 1
548 } else if headerCount > 0 {
549 break
550 }
551 }
552
553 guard headerCount >= 2, let blankLineIndex else {
554 return body
555 }
556
557 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
558 }
559
560 nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
561 segmentMessageBody(body, isPatch: isPatch)
562 }
563
564 private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
565 guard isPatch else {
566 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
567 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
568 }
569
570 let normalizedBody = normalizeLineEndings(in: body)
571 let lines = normalizedBody.components(separatedBy: "\n")
572 guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
573 let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
574 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
575 }
576
577 var blocks: [InboxMessageContentBlock] = []
578 let leadingPlainText = lines[..<diffStartIndex]
579 .joined(separator: "\n")
580 .trimmingCharacters(in: .whitespacesAndNewlines)
581 if !leadingPlainText.isEmpty {
582 blocks.append(.plainText(leadingPlainText))
583 }
584
585 let remainingLines = Array(lines[diffStartIndex...])
586 let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
587
588 let diffLines: ArraySlice<String>
589 let trailingPlainText: String
590 if let signatureIndex {
591 diffLines = remainingLines[..<signatureIndex]
592 trailingPlainText = remainingLines[signatureIndex...]
593 .joined(separator: "\n")
594 .trimmingCharacters(in: .whitespacesAndNewlines)
595 } else {
596 diffLines = remainingLines[...]
597 trailingPlainText = ""
598 }
599
600 let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
601 if !diff.isEmpty {
602 blocks.append(.diff(diff))
603 }
604
605 if !trailingPlainText.isEmpty {
606 blocks.append(.plainText(trailingPlainText))
607 }
608 return blocks
609 }
610
611 private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
612 if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
613 return explicitDiffIndex
614 }
615
616 for index in lines.indices {
617 let line = lines[index]
618 guard line.hasPrefix("--- ") else { continue }
619 let nextIndex = lines.index(after: index)
620 guard nextIndex < lines.endIndex else { continue }
621 let nextLine = lines[nextIndex]
622 guard nextLine.hasPrefix("+++ ") else { continue }
623
624 let oldPath = String(line.dropFirst(4))
625 let newPath = String(nextLine.dropFirst(4))
626 let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
627 (newPath.hasPrefix("b/") || newPath == "/dev/null")
628
629 if looksLikeUnifiedDiff {
630 return index
631 }
632 }
633
634 return nil
635 }
636
637 private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
638 line == "-- " || line == "--"
639 }
640
641 private nonisolated static func normalizeLineEndings(in text: String) -> String {
642 text
643 .replacingOccurrences(of: "\r\n", with: "\n")
644 .replacingOccurrences(of: "\r", with: "\n")
645 }
646
647 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
648 let prefix = "\(headerName):"
649 let lines = body.components(separatedBy: .newlines)
650 for line in lines.prefix(12) {
651 if line.isEmpty {
652 break
653 }
654 if line.hasPrefix(prefix) {
655 return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
656 }
657 }
658 return nil
659 }
660
661 private static func executeGraphQLRequest<T: Decodable>(
662 client: SRHTClient,
663 query: String,
664 variables: [String: any Sendable]
665 ) async throws -> T {
666 guard let token = KeychainHelper.loadToken(), !token.isEmpty else {
667 throw SRHTError.unauthorized
668 }
669
670 var request = URLRequest(url: SRHTService.lists.url)
671 request.httpMethod = "POST"
672 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
673 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
674
675 let encoder = JSONEncoder()
676 request.httpBody = try encoder.encode(
677 GraphQLRequestBody(
678 query: query,
679 variables: variables.mapValues { AnyCodable($0) }
680 )
681 )
682
683 let (data, _) = try await URLSession.shared.data(for: request)
684 #if DEBUG
685 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
686 inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
687 #endif
688
689 let decoder = JSONDecoder()
690 decoder.dateDecodingStrategy = .srhtFlexible
691 let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
692 if let errors = envelope.errors, !errors.isEmpty {
693 throw SRHTError.graphQLErrors(errors)
694 }
695 guard let payload = envelope.data else {
696 throw SRHTError.decodingError(
697 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response"))
698 )
699 }
700 return payload
701 }
702}
Hutch/Views/Repositories/DiffView.swift +15 −7
@@ -9,14 +9,23 @@ struct DiffView: View {
99 let diff: String
1010
1111 var body: some View {
12 let lines = diff.components(separatedBy: "\n")
12 let lines = normalizedDiff.components(separatedBy: "\n")
1313
14 LazyVStack(alignment: .leading, spacing: 0) {
14 VStack(alignment: .leading, spacing: 0) {
1515 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
1616 DiffLineView(line: line)
1717 }
1818 }
19 .font(.caption.monospaced())
19 .font(.system(.caption, design: .monospaced))
20 .frame(maxWidth: .infinity, alignment: .leading)
21 .background(Color(.secondarySystemBackground))
22 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
23 }
24
25 private var normalizedDiff: String {
26 diff
27 .replacingOccurrences(of: "\r\n", with: "\n")
28 .replacingOccurrences(of: "\r", with: "\n")
2029 }
2130}
2231
@@ -27,7 +36,6 @@ private struct DiffLineView: View {
2736 Text(line.isEmpty ? " " : line)
2837 .frame(maxWidth: .infinity, alignment: .leading)
2938 .padding(.horizontal, 8)
30 .padding(.vertical, 1)
3139 .background(backgroundColor)
3240 .foregroundStyle(foregroundColor)
3341 .fontWeight(isHeader ? .semibold : .regular)
@@ -47,9 +55,9 @@ private struct DiffLineView: View {
4755 switch kind {
4856 case .added: .green.opacity(0.15)
4957 case .removed: .red.opacity(0.15)
50 case .hunk: .gray.opacity(0.12)
51 case .fileHeader: .gray.opacity(0.08)
52 case .meta: .gray.opacity(0.05)
58 case .hunk: .clear
59 case .fileHeader: .clear
60 case .meta: .clear
5361 case .context: .clear
5462 }
5563 }
HutchTests/InboxViewModelTests.swift added +168
@@ -0,0 +1,168 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct InboxViewModelTests {
6
7 @Test
8 func derivesRepositoryNameFromCommonPatchListSuffixes() {
9 #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut")
10 #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git")
11 #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil)
12 }
13
14 @Test
15 func parsesMailtoDraft() {
16 let draft = ThreadViewModel.mailComposeDraft(
17 from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM"
18 )
19
20 #expect(draft?.recipients == ["list@example.com"])
21 #expect(draft?.ccRecipients == ["author@example.com"])
22 #expect(draft?.subject == "Re: PATCH")
23 #expect(draft?.body == "LGTM")
24 }
25
26 @Test
27 func computesLocalUnreadStateFromLastViewedMarker() {
28 let suiteName = "InboxViewModelTests-\(UUID().uuidString)"
29 let defaults = UserDefaults(suiteName: suiteName)!
30 defer { defaults.removePersistentDomain(forName: suiteName) }
31
32 let threadID = "list#message"
33 let lastActivity = Date(timeIntervalSince1970: 2_000)
34
35 #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
36
37 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults)
38 #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
39
40 InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults)
41 #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults))
42 }
43
44 @Test
45 func normalizesThreadSubjectsForDisplay() {
46 let summary = InboxThreadSummary(
47 rootEmailID: 1,
48 rootMessageID: "message",
49 threadRootEmailIDs: [1],
50 threadRootMessageIDs: ["message"],
51 listID: 2,
52 listRID: "list",
53 listName: "hut-devel",
54 listOwner: Entity(canonicalName: "~owner"),
55 subject: "Re: Fwd: [PATCH] test: add parser ",
56 latestSender: Entity(canonicalName: "~sender"),
57 lastActivityAt: Date(timeIntervalSince1970: 2_000),
58 messageCount: 3,
59 repo: "hut",
60 containsPatch: true,
61 isUnread: true
62 )
63
64 #expect(summary.displaySubject == "[PATCH] test: add parser")
65 #expect(summary.metadataLine.contains("~sender"))
66 #expect(summary.metadataLine.contains("2 replies"))
67 }
68
69 @Test
70 func keepsDistinctThreadsDistinctByRootMessageID() {
71 let baseList = InboxMailingListReference(
72 id: 1,
73 rid: "list",
74 name: "hut-devel",
75 owner: Entity(canonicalName: "~owner")
76 )
77
78 let first = InboxThreadSummary(
79 rootEmailID: 10,
80 rootMessageID: "message-1",
81 threadRootEmailIDs: [10],
82 threadRootMessageIDs: ["message-1"],
83 listID: baseList.id,
84 listRID: baseList.rid,
85 listName: baseList.name,
86 listOwner: baseList.owner,
87 subject: "[PATCH] test",
88 latestSender: Entity(canonicalName: "~a"),
89 lastActivityAt: Date(timeIntervalSince1970: 100),
90 messageCount: 1,
91 repo: "hut",
92 containsPatch: true,
93 isUnread: true
94 )
95 let second = InboxThreadSummary(
96 rootEmailID: 11,
97 rootMessageID: "message-2",
98 threadRootEmailIDs: [11],
99 threadRootMessageIDs: ["message-2"],
100 listID: baseList.id,
101 listRID: baseList.rid,
102 listName: baseList.name,
103 listOwner: baseList.owner,
104 subject: "[PATCH] test",
105 latestSender: Entity(canonicalName: "~b"),
106 lastActivityAt: Date(timeIntervalSince1970: 200),
107 messageCount: 2,
108 repo: "hut",
109 containsPatch: true,
110 isUnread: true
111 )
112
113 #expect(first.id != second.id)
114 }
115
116 @Test
117 func segmentsPatchBodyAndTreatsSignatureAsPlainText() {
118 let body = """
119 From: Christian Cleberg <hello@cleberg.net>
120
121 ---
122 test-patch.txt | 1 +
123 1 file changed, 1 insertion(+)
124 create mode 100644 test-patch.txt
125
126 diff --git a/test-patch.txt b/test-patch.txt
127 new file mode 100644
128 index 0000000..c7b6eed
129 --- /dev/null
130 +++ b/test-patch.txt
131 @@ -0,0 +1 @@
132 +test Wed Mar 18 23:19:03 CDT 2026
133 --
134 2.50.1 (Apple Git-155)
135 """
136
137 let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true)
138
139 #expect(segments.count == 3)
140
141 guard case let .plainText(leadingPlainText) = segments[0] else {
142 Issue.record("Expected first segment to be plain text")
143 return
144 }
145 #expect(leadingPlainText.contains("From: Christian Cleberg <hello@cleberg.net>"))
146 #expect(leadingPlainText.contains("---"))
147 #expect(leadingPlainText.contains(" test-patch.txt | 1 +"))
148 #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)"))
149 #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt"))
150
151 guard case let .diff(diff) = segments[1] else {
152 Issue.record("Expected second segment to be diff")
153 return
154 }
155 #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt"))
156 #expect(diff.contains("--- /dev/null"))
157 #expect(diff.contains("+++ b/test-patch.txt"))
158 #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026"))
159 #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)"))
160
161 guard case let .plainText(trailingPlainText) = segments[2] else {
162 Issue.record("Expected third segment to be plain text")
163 return
164 }
165 #expect(trailingPlainText.contains("--"))
166 #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)"))
167 }
168}