Commit fa8188260f

fa8188260f99cda75d2d6ed993e54b6ce8c5282d

parent: af758cc55f

Unsigned

cmc <hello@cleberg.net> · 2026-03-19 21:59 UTC

v2.1: bundled polish and fixes

Layout: unified · split

Hutch/App/RootView.swift +16 −14
@@ -146,12 +146,10 @@ struct RootView: View {
146 resolveRepositoryLink(owner: owner, repo: repo) 146 resolveRepositoryLink(owner: owner, repo: repo)
147 147
148 case .build(let jobId): 148 case .build(let jobId):
149 // Reset the builds navigation and push the detail
150 buildsPath = NavigationPath() 149 buildsPath = NavigationPath()
151 appState.selectedTab = .builds 150 appState.selectedTab = .builds
152 // Defer the push slightly so the tab switch takes effect 151 Task {
153 Task { @MainActor in 152 await settleNavigationTransition()
154 try? await Task.sleep(for: .milliseconds(100))
155 buildsPath.append(jobId) 153 buildsPath.append(jobId)
156 } 154 }
157 155
@@ -165,26 +163,25 @@ struct RootView: View {
165 case .repository(let repository): 163 case .repository(let repository):
166 repoPath = NavigationPath() 164 repoPath = NavigationPath()
167 appState.selectedTab = .repositories 165 appState.selectedTab = .repositories
168 Task { @MainActor in 166 Task {
169 try? await Task.sleep(for: .milliseconds(100)) 167 await settleNavigationTransition()
170 repoPath.append(repository) 168 repoPath.append(repository)
171 } 169 }
172 170
173 case .tracker(let tracker): 171 case .tracker(let tracker):
174 ticketsPath = NavigationPath() 172 ticketsPath = NavigationPath()
175 appState.selectedTab = .tickets 173 appState.selectedTab = .tickets
176 Task { @MainActor in 174 Task {
177 try? await Task.sleep(for: .milliseconds(100)) 175 await settleNavigationTransition()
178 ticketsPath.append(tracker) 176 ticketsPath.append(tracker)
179 } 177 }
180 178
181 case .mailingList(let mailingList): 179 case .mailingList(let mailingList):
182 morePath = NavigationPath() 180 morePath = NavigationPath()
183 appState.selectedTab = .more 181 appState.selectedTab = .more
184 Task { @MainActor in 182 Task {
185 try? await Task.sleep(for: .milliseconds(100)) 183 await settleNavigationTransition()
186 morePath.append(MoreRoute.lists) 184 morePath.append(MoreRoute.lists)
187 try? await Task.sleep(for: .milliseconds(100))
188 morePath.append(MoreRoute.mailingList(mailingList)) 185 morePath.append(MoreRoute.mailingList(mailingList))
189 } 186 }
190 } 187 }
@@ -198,7 +195,7 @@ struct RootView: View {
198 let summary = try await appState.resolveRepository(owner: owner, name: repo) 195 let summary = try await appState.resolveRepository(owner: owner, name: repo)
199 repoPath = NavigationPath() 196 repoPath = NavigationPath()
200 appState.selectedTab = .repositories 197 appState.selectedTab = .repositories
201 try? await Task.sleep(for: .milliseconds(100)) 198 await settleNavigationTransition()
202 repoPath.append(summary) 199 repoPath.append(summary)
203 } catch { 200 } catch {
204 // Silently fail — the repo may not exist or be inaccessible 201 // Silently fail — the repo may not exist or be inaccessible
@@ -214,9 +211,8 @@ struct RootView: View {
214 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) 211 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
215 ticketsPath = NavigationPath() 212 ticketsPath = NavigationPath()
216 appState.selectedTab = .tickets 213 appState.selectedTab = .tickets
217 try? await Task.sleep(for: .milliseconds(100)) 214 await settleNavigationTransition()
218 ticketsPath.append(trackerSummary) 215 ticketsPath.append(trackerSummary)
219 try? await Task.sleep(for: .milliseconds(100))
220 ticketsPath.append(TicketDeepLinkTarget( 216 ticketsPath.append(TicketDeepLinkTarget(
221 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()), 217 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
222 trackerName: trackerSummary.name, 218 trackerName: trackerSummary.name,
@@ -229,6 +225,12 @@ struct RootView: View {
229 } 225 }
230 } 226 }
231 } 227 }
228
229 @MainActor
230 private func settleNavigationTransition() async {
231 await Task.yield()
232 await Task.yield()
233 }
232} 234}
233 235
234enum MoreDestination: Hashable { 236enum MoreDestination: Hashable {
Hutch/Networking/SRHTError.swift +72
@@ -33,6 +33,55 @@ enum SRHTError: LocalizedError, Sendable {
33 } 33 }
34 } 34 }
35 35
36 var userFacingMessage: String {
37 switch self {
38 case .graphQLErrors(let errors):
39 let firstMessage = errors.first?.message.lowercased() ?? ""
40 if firstMessage.contains("unauthorized") || firstMessage.contains("forbidden") {
41 return "You do not have permission to do that."
42 }
43 if firstMessage.contains("not found") || firstMessage.contains("no rows in result set") {
44 return "That content is no longer available."
45 }
46 return "Something went wrong. Please try again."
47 case .httpError(let code):
48 if code == 401 {
49 return "Please sign in again."
50 }
51 if code == 403 {
52 return "You do not have permission to do that."
53 }
54 if code == 404 {
55 return "That content is no longer available."
56 }
57 if (500...599).contains(code) {
58 return "The server is unavailable right now. Please try again."
59 }
60 return "Something went wrong. Please try again."
61 case .invalidAuthenticatedURL:
62 return "That request could not be completed."
63 case .decodingError:
64 return "The response could not be loaded right now."
65 case .networkError(let error):
66 let nsError = error as NSError
67 switch nsError.code {
68 case NSURLErrorNotConnectedToInternet,
69 NSURLErrorNetworkConnectionLost,
70 NSURLErrorTimedOut,
71 NSURLErrorCannotFindHost,
72 NSURLErrorCannotConnectToHost,
73 NSURLErrorDNSLookupFailed,
74 NSURLErrorInternationalRoamingOff,
75 NSURLErrorDataNotAllowed:
76 return "Check your connection and try again."
77 default:
78 return "The network request failed. Please try again."
79 }
80 case .unauthorized:
81 return "Please sign in again."
82 }
83 }
84
36 /// Whether this error represents a connectivity issue (no internet, timeout, DNS). 85 /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
37 var isConnectivityError: Bool { 86 var isConnectivityError: Bool {
38 switch self { 87 switch self {
@@ -55,6 +104,29 @@ enum SRHTError: LocalizedError, Sendable {
55 } 104 }
56} 105}
57 106
107extension Error {
108 var userFacingMessage: String {
109 if let error = self as? SRHTError {
110 return error.userFacingMessage
111 }
112
113 let nsError = self as NSError
114 switch nsError.code {
115 case NSURLErrorNotConnectedToInternet,
116 NSURLErrorNetworkConnectionLost,
117 NSURLErrorTimedOut,
118 NSURLErrorCannotFindHost,
119 NSURLErrorCannotConnectToHost,
120 NSURLErrorDNSLookupFailed,
121 NSURLErrorInternationalRoamingOff,
122 NSURLErrorDataNotAllowed:
123 return "Check your connection and try again."
124 default:
125 return "Something went wrong. Please try again."
126 }
127 }
128}
129
58/// A single error entry from the GraphQL `errors` array. 130/// A single error entry from the GraphQL `errors` array.
59struct GraphQLError: Decodable, Sendable { 131struct GraphQLError: Decodable, Sendable {
60 let message: String 132 let message: String
Hutch/Views/Auth/AuthView.swift +1 −1
@@ -87,7 +87,7 @@ struct TokenEntryView: View {
87 do { 87 do {
88 try await appState.connect(with: tokenTrimmed) 88 try await appState.connect(with: tokenTrimmed)
89 } catch { 89 } catch {
90 errorMessage = error.localizedDescription 90 errorMessage = error.userFacingMessage
91 } 91 }
92 isConnecting = false 92 isConnecting = false
93 } 93 }
Hutch/Views/Builds/BuildDetailViewModel.swift +5 −5
@@ -110,7 +110,7 @@ final class BuildDetailViewModel {
110 } 110 }
111 job = loadedJob 111 job = loadedJob
112 } catch { 112 } catch {
113 self.error = error.localizedDescription 113 self.error = error.userFacingMessage
114 } 114 }
115 115
116 isLoading = false 116 isLoading = false
@@ -127,7 +127,7 @@ final class BuildDetailViewModel {
127 do { 127 do {
128 taskLogs[cacheKey] = try await client.fetchText(url: logURL) 128 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
129 } catch { 129 } catch {
130 self.error = error.localizedDescription 130 self.error = error.userFacingMessage
131 } 131 }
132 132
133 loadingTaskLogs.remove(cacheKey) 133 loadingTaskLogs.remove(cacheKey)
@@ -148,7 +148,7 @@ final class BuildDetailViewModel {
148 // Reload job to get updated status. 148 // Reload job to get updated status.
149 await loadJob() 149 await loadJob()
150 } catch { 150 } catch {
151 self.error = error.localizedDescription 151 self.error = error.userFacingMessage
152 } 152 }
153 153
154 isCancelling = false 154 isCancelling = false
@@ -185,7 +185,7 @@ final class BuildDetailViewModel {
185 ) 185 )
186 return result.submit.id 186 return result.submit.id
187 } catch { 187 } catch {
188 self.error = error.localizedDescription 188 self.error = error.userFacingMessage
189 return nil 189 return nil
190 } 190 }
191 } 191 }
@@ -233,7 +233,7 @@ final class BuildDetailViewModel {
233 ) 233 )
234 return result.submit.id 234 return result.submit.id
235 } catch { 235 } catch {
236 self.error = "Couldn’t submit the build. \(error.localizedDescription)" 236 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
237 return nil 237 return nil
238 } 238 }
239 } 239 }
Hutch/Views/Builds/BuildListView.swift +1
@@ -23,6 +23,7 @@ struct BuildListView: View {
23 } label: { 23 } label: {
24 Image(systemName: "plus") 24 Image(systemName: "plus")
25 } 25 }
26 .accessibilityLabel("Submit build")
26 } 27 }
27 } 28 }
28 } 29 }
Hutch/Views/Builds/BuildListViewModel.swift +3 −3
@@ -97,7 +97,7 @@ final class BuildListViewModel {
97 hasMore = page.cursor != nil 97 hasMore = page.cursor != nil
98 } catch { 98 } catch {
99 if jobs.isEmpty { 99 if jobs.isEmpty {
100 self.error = error.localizedDescription 100 self.error = error.userFacingMessage
101 } 101 }
102 } 102 }
103 103
@@ -121,7 +121,7 @@ final class BuildListViewModel {
121 cursor = page.cursor 121 cursor = page.cursor
122 hasMore = page.cursor != nil 122 hasMore = page.cursor != nil
123 } catch { 123 } catch {
124 self.error = error.localizedDescription 124 self.error = error.userFacingMessage
125 } 125 }
126 126
127 isLoadingMore = false 127 isLoadingMore = false
@@ -171,7 +171,7 @@ final class BuildListViewModel {
171 await loadJobs() 171 await loadJobs()
172 return result.submit.id 172 return result.submit.id
173 } catch { 173 } catch {
174 self.error = "Couldn’t submit the build. \(error.localizedDescription)" 174 self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
175 return nil 175 return nil
176 } 176 }
177 } 177 }
Hutch/Views/Home/HomeViewModel.swift +3 −3
@@ -285,8 +285,8 @@ final class HomeViewModel {
285 case .failure(let error): 285 case .failure(let error):
286 self.recentBuilds = [] 286 self.recentBuilds = []
287 self.failedBuilds = [] 287 self.failedBuilds = []
288 self.failedBuildsError = error.localizedDescription 288 self.failedBuildsError = error.userFacingMessage
289 self.recentBuildsError = error.localizedDescription 289 self.recentBuildsError = error.userFacingMessage
290 } 290 }
291 isLoadingFailedBuilds = false 291 isLoadingFailedBuilds = false
292 isLoadingRecentBuilds = false 292 isLoadingRecentBuilds = false
@@ -299,7 +299,7 @@ final class HomeViewModel {
299 self.assignedTicketsError = nil 299 self.assignedTicketsError = nil
300 case .failure(let error): 300 case .failure(let error):
301 self.assignedTickets = [] 301 self.assignedTickets = []
302 self.assignedTicketsError = error.localizedDescription 302 self.assignedTicketsError = error.userFacingMessage
303 } 303 }
304 isLoadingAssignedTickets = false 304 isLoadingAssignedTickets = false
305 305
Hutch/Views/Inbox/InboxView.swift −15
@@ -1,7 +1,4 @@
1import SwiftUI 1import SwiftUI
2import os
3
4private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation")
5 2
6struct InboxView: View { 3struct InboxView: View {
7 @Environment(AppState.self) private var appState 4 @Environment(AppState.self) private var appState
@@ -101,9 +98,6 @@ struct InboxView: View {
101 systemImage: "tray", 98 systemImage: "tray",
102 description: Text("This thread could not be restored.") 99 description: Text("This thread could not be restored.")
103 ) 100 )
104 .onAppear {
105 inboxNavigationLogger.error("Inbox navigation destination missing thread snapshot")
106 }
107 } 101 }
108 } 102 }
109 } 103 }
@@ -123,9 +117,6 @@ struct InboxView: View {
123 private func selectThread(_ thread: InboxThreadSummary) { 117 private func selectThread(_ thread: InboxThreadSummary) {
124 cacheSelectedThread(thread) 118 cacheSelectedThread(thread)
125 isShowingThreadDetail = true 119 isShowingThreadDetail = true
126 inboxNavigationLogger.debug(
127 "Inbox navigation triggered: threadID=\(thread.id, privacy: .public) subject=\(thread.subject, privacy: .public)"
128 )
129 } 120 }
130 121
131 private func cacheSelectedThread(_ thread: InboxThreadSummary) { 122 private func cacheSelectedThread(_ thread: InboxThreadSummary) {
@@ -140,9 +131,6 @@ struct InboxView: View {
140 131
141 private func handleThreadDetailDisappear(for threadID: String) { 132 private func handleThreadDetailDisappear(for threadID: String) {
142 let isActiveSelection = selectedThreadID == threadID 133 let isActiveSelection = selectedThreadID == threadID
143 inboxNavigationLogger.debug(
144 "Inbox thread detail disappeared: threadID=\(threadID, privacy: .public) activeSelection=\(isActiveSelection, privacy: .public)"
145 )
146 guard isActiveSelection else { return } 134 guard isActiveSelection else { return }
147 clearSelection() 135 clearSelection()
148 } 136 }
@@ -154,9 +142,6 @@ struct InboxView: View {
154 } 142 }
155 143
156 private func clearSelection() { 144 private func clearSelection() {
157 if let selectedThreadID {
158 inboxNavigationLogger.debug("Inbox selection cleared: threadID=\(selectedThreadID, privacy: .public)")
159 }
160 selectedThreadID = nil 145 selectedThreadID = nil
161 selectedThreadSnapshot = nil 146 selectedThreadSnapshot = nil
162 isShowingThreadDetail = false 147 isShowingThreadDetail = false
Hutch/Views/Inbox/InboxViewModel.swift +2 −15
@@ -136,7 +136,7 @@ final class InboxViewModel {
136 return lhs.lastActivityAt > rhs.lastActivityAt 136 return lhs.lastActivityAt > rhs.lastActivityAt
137 } 137 }
138 } catch { 138 } catch {
139 inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)") 139 inboxListLogger.error("Inbox request failed")
140 self.error = "Failed to load inbox" 140 self.error = "Failed to load inbox"
141 } 141 }
142 } 142 }
@@ -144,17 +144,11 @@ final class InboxViewModel {
144 func markThreadRead(_ thread: InboxThreadSummary) { 144 func markThreadRead(_ thread: InboxThreadSummary) {
145 let viewedAt = max(Date(), thread.lastActivityAt) 145 let viewedAt = max(Date(), thread.lastActivityAt)
146 InboxReadStateStore.markViewed(viewedAt, for: thread.id) 146 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
147 inboxListLogger.debug(
148 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
149 )
150 threads.removeAll { $0.id == thread.id } 147 threads.removeAll { $0.id == thread.id }
151 } 148 }
152 149
153 func markThreadUnread(_ thread: InboxThreadSummary) { 150 func markThreadUnread(_ thread: InboxThreadSummary) {
154 InboxReadStateStore.markUnread(for: thread.id) 151 InboxReadStateStore.markUnread(for: thread.id)
155 inboxListLogger.debug(
156 "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil"
157 )
158 updateThread(thread, isUnread: true) 152 updateThread(thread, isUnread: true)
159 } 153 }
160 154
@@ -237,7 +231,7 @@ final class InboxViewModel {
237 summaries.append(contentsOf: batchResult.0) 231 summaries.append(contentsOf: batchResult.0)
238 failureMessages.append(contentsOf: batchResult.1) 232 failureMessages.append(contentsOf: batchResult.1)
239 for failure in batchResult.1 { 233 for failure in batchResult.1 {
240 inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)") 234 inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
241 } 235 }
242 startIndex = endIndex 236 startIndex = endIndex
243 } 237 }
@@ -259,14 +253,7 @@ final class InboxViewModel {
259 253
260 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in 254 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
261 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())" 255 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())"
262 let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey)
263 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated) 256 let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
264 inboxListLogger.debug(
265 "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)"
266 )
267 inboxListLogger.debug(
268 "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)"
269 )
270 return InboxThreadSummary( 257 return InboxThreadSummary(
271 rootEmailID: thread.root.id, 258 rootEmailID: thread.root.id,
272 rootMessageID: thread.root.messageID, 259 rootMessageID: thread.root.messageID,
Hutch/Views/Inbox/ThreadDetailView.swift +3 −11
@@ -4,7 +4,6 @@ import SwiftUI
4import UIKit 4import UIKit
5 5
6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") 6private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply")
7private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation")
8 7
9struct ThreadDetailView: View { 8struct ThreadDetailView: View {
10 let thread: InboxThreadSummary 9 let thread: InboxThreadSummary
@@ -53,18 +52,12 @@ struct ThreadDetailView: View {
53 isUnread = thread.isUnread 52 isUnread = thread.isUnread
54 await vm.loadThread() 53 await vm.loadThread()
55 } 54 }
56 .onAppear {
57 inboxThreadNavigationLogger.debug("Inbox thread detail appeared: threadID=\(thread.id, privacy: .public)")
58 }
59 .onChange(of: viewModel?.thread?.id) { _, threadID in 55 .onChange(of: viewModel?.thread?.id) { _, threadID in
60 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return } 56 guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
61 hasMarkedCurrentThreadViewed = true 57 hasMarkedCurrentThreadViewed = true
62 isUnread = false 58 isUnread = false
63 onViewed() 59 onViewed()
64 } 60 }
65 .onDisappear {
66 inboxThreadNavigationLogger.debug("Inbox thread detail view disappeared: threadID=\(thread.id, privacy: .public)")
67 }
68 .sheet(item: Binding( 61 .sheet(item: Binding(
69 get: { viewModel?.composeDraft }, 62 get: { viewModel?.composeDraft },
70 set: { _ in viewModel?.dismissReply() } 63 set: { _ in viewModel?.dismissReply() }
@@ -72,14 +65,13 @@ struct ThreadDetailView: View {
72 MailComposeView(draft: draft) { result in 65 MailComposeView(draft: draft) { result in
73 switch result { 66 switch result {
74 case .failed(let message): 67 case .failed(let message):
75 inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)") 68 inboxReplyLogger.error("Inbox reply failed")
76 viewModel?.error = message 69 viewModel?.error = message
77 case .cancelled: 70 case .cancelled:
78 inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)") 71 break
79 case .saved: 72 case .saved:
80 inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)") 73 break
81 case .sent: 74 case .sent:
82 inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)")
83 replySuccessMessage = "Reply handed off to Mail." 75 replySuccessMessage = "Reply handed off to Mail."
84 Task { 76 Task {
85 await viewModel?.loadThread() 77 await viewModel?.loadThread()
Hutch/Views/Inbox/ThreadViewModel.swift +6 −41
@@ -172,8 +172,6 @@ final class ThreadViewModel {
172 partialWarning = nil 172 partialWarning = nil
173 defer { isLoading = false } 173 defer { isLoading = false }
174 174
175 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
176
177 do { 175 do {
178 let threadPayloads = try await fetchThreadPayloads() 176 let threadPayloads = try await fetchThreadPayloads()
179 177
@@ -207,9 +205,7 @@ final class ThreadViewModel {
207 } 205 }
208 } catch { 206 } catch {
209 hadPartialReplyFailure = true 207 hadPartialReplyFailure = true
210 inboxLogger.error( 208 inboxLogger.error("Inbox thread descendants failed")
211 "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
212 )
213 } 209 }
214 } 210 }
215 211
@@ -241,9 +237,9 @@ final class ThreadViewModel {
241 if thread == nil { 237 if thread == nil {
242 self.error = "Failed to load thread" 238 self.error = "Failed to load thread"
243 } else { 239 } else {
244 self.error = error.localizedDescription 240 self.error = error.userFacingMessage
245 } 241 }
246 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") 242 inboxLogger.error("Inbox thread detail failed")
247 } 243 }
248 } 244 }
249 245
@@ -275,17 +271,9 @@ final class ThreadViewModel {
275 271
276 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { 272 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
277 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID) 273 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
278 inboxLogger.debug(
279 "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)"
280 )
281
282 var lastLookupError: Error? 274 var lastLookupError: Error?
283 275
284 for messageID in candidateMessageIDs { 276 for messageID in candidateMessageIDs {
285 inboxLogger.debug(
286 "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
287 )
288
289 do { 277 do {
290 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( 278 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
291 client: client, 279 client: client,
@@ -303,10 +291,6 @@ final class ThreadViewModel {
303 } catch let error as SRHTError { 291 } catch let error as SRHTError {
304 switch error { 292 switch error {
305 case .graphQLErrors(let errors): 293 case .graphQLErrors(let errors):
306 let combinedMessage = errors.map(\.message).joined(separator: " | ")
307 inboxLogger.error(
308 "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
309 )
310 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) { 294 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
311 lastLookupError = error 295 lastLookupError = error
312 continue 296 continue
@@ -318,11 +302,7 @@ final class ThreadViewModel {
318 } 302 }
319 } 303 }
320 304
321 if let lastLookupError { 305 _ = lastLookupError
322 inboxLogger.debug(
323 "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
324 )
325 }
326 return nil 306 return nil
327 } 307 }
328 308
@@ -348,7 +328,7 @@ final class ThreadViewModel {
348 ) 328 )
349 } catch { 329 } catch {
350 if Self.isRecoverableNoRows(error) { 330 if Self.isRecoverableNoRows(error) {
351 inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") 331 inboxLogger.error("Inbox thread page scan missed a recoverable result")
352 return nil 332 return nil
353 } 333 }
354 throw error 334 throw error
@@ -358,11 +338,6 @@ final class ThreadViewModel {
358 return nil 338 return nil
359 } 339 }
360 340
361 let candidates = threadPage.results.map { payload in
362 "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
363 }.joined(separator: " | ")
364 inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
365
366 if let matchedThread = threadPage.results.first(where: { 341 if let matchedThread = threadPage.results.first(where: {
367 $0.root?.messageID == targetRootMessageID || 342 $0.root?.messageID == targetRootMessageID ||
368 $0.root?.id == summary.rootEmailID || 343 $0.root?.id == summary.rootEmailID ||
@@ -428,9 +403,7 @@ final class ThreadViewModel {
428 ) 403 )
429 } catch { 404 } catch {
430 if Self.isRecoverableNoRows(error) { 405 if Self.isRecoverableNoRows(error) {
431 inboxLogger.error( 406 inboxLogger.error("Inbox descendant page missed a recoverable result")
432 "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
433 )
434 continue 407 continue
435 } 408 }
436 throw error 409 throw error
@@ -449,9 +422,6 @@ final class ThreadViewModel {
449 error = "This thread is not ready to reply to yet." 422 error = "This thread is not ready to reply to yet."
450 return 423 return
451 } 424 }
452 inboxLogger.debug(
453 "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"
454 )
455 composeDraft = MailComposeDraft( 425 composeDraft = MailComposeDraft(
456 recipients: [thread.replyRecipient], 426 recipients: [thread.replyRecipient],
457 ccRecipients: [], 427 ccRecipients: [],
@@ -732,11 +702,6 @@ final class ThreadViewModel {
732 ) 702 )
733 703
734 let (data, _) = try await URLSession.shared.data(for: request) 704 let (data, _) = try await URLSession.shared.data(for: request)
735 #if DEBUG
736 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
737 inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
738 #endif
739
740 let decoder = JSONDecoder() 705 let decoder = JSONDecoder()
741 decoder.dateDecodingStrategy = .srhtFlexible 706 decoder.dateDecodingStrategy = .srhtFlexible
742 let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data) 707 let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
Hutch/Views/Pastes/PasteDetailViewModel.swift +4 −4
@@ -47,7 +47,7 @@ final class PasteDetailViewModel {
47 } 47 }
48 await loadSelectedFileContentsIfNeeded() 48 await loadSelectedFileContentsIfNeeded()
49 } catch { 49 } catch {
50 self.error = error.localizedDescription 50 self.error = error.userFacingMessage
51 } 51 }
52 } 52 }
53 53
@@ -77,7 +77,7 @@ final class PasteDetailViewModel {
77 } 77 }
78 return updatedPaste 78 return updatedPaste
79 } catch { 79 } catch {
80 self.error = error.localizedDescription 80 self.error = error.userFacingMessage
81 return nil 81 return nil
82 } 82 }
83 } 83 }
@@ -92,7 +92,7 @@ final class PasteDetailViewModel {
92 _ = try await service.deletePaste(id: pasteID) 92 _ = try await service.deletePaste(id: pasteID)
93 return true 93 return true
94 } catch { 94 } catch {
95 self.error = error.localizedDescription 95 self.error = error.userFacingMessage
96 return false 96 return false
97 } 97 }
98 } 98 }
@@ -108,7 +108,7 @@ final class PasteDetailViewModel {
108 do { 108 do {
109 fileContents[file.hash] = try await service.loadContents(from: url) 109 fileContents[file.hash] = try await service.loadContents(from: url)
110 } catch { 110 } catch {
111 self.error = error.localizedDescription 111 self.error = error.userFacingMessage
112 } 112 }
113 } 113 }
114} 114}
Hutch/Views/Pastes/PasteListViewModel.swift +3 −3
@@ -41,7 +41,7 @@ final class PasteListViewModel {
41 hasMore = page.cursor != nil 41 hasMore = page.cursor != nil
42 } catch { 42 } catch {
43 if pastes.isEmpty { 43 if pastes.isEmpty {
44 self.error = error.localizedDescription 44 self.error = error.userFacingMessage
45 } 45 }
46 } 46 }
47 47
@@ -66,7 +66,7 @@ final class PasteListViewModel {
66 cursor = page.cursor 66 cursor = page.cursor
67 hasMore = page.cursor != nil 67 hasMore = page.cursor != nil
68 } catch { 68 } catch {
69 self.error = error.localizedDescription 69 self.error = error.userFacingMessage
70 } 70 }
71 } 71 }
72 72
@@ -90,7 +90,7 @@ final class PasteListViewModel {
90 upsertPaste(paste) 90 upsertPaste(paste)
91 return paste 91 return paste
92 } catch { 92 } catch {
93 self.error = error.localizedDescription 93 self.error = error.userFacingMessage
94 return nil 94 return nil
95 } 95 }
96 } 96 }
Hutch/Views/Repositories/CommitDetailViewModel.swift +1 −1
@@ -68,7 +68,7 @@ final class CommitDetailViewModel {
68 let result = try await executeWithRetry() 68 let result = try await executeWithRetry()
69 commit = result.repository?.revparse_single 69 commit = result.repository?.revparse_single
70 } catch { 70 } catch {
71 self.error = error.localizedDescription 71 self.error = error.userFacingMessage
72 } 72 }
73 73
74 isLoading = false 74 isLoading = false
Hutch/Views/Repositories/FileTreeViewModel.swift +4 −4
@@ -294,7 +294,7 @@ final class FileTreeViewModel {
294 entries = [] 294 entries = []
295 } 295 }
296 } catch { 296 } catch {
297 self.error = error.localizedDescription 297 self.error = error.userFacingMessage
298 } 298 }
299 } 299 }
300 300
@@ -372,7 +372,7 @@ final class FileTreeViewModel {
372 } 372 }
373 entries = allEntries 373 entries = allEntries
374 } catch { 374 } catch {
375 self.error = error.localizedDescription 375 self.error = error.userFacingMessage
376 } 376 }
377 } 377 }
378 378
@@ -397,7 +397,7 @@ final class FileTreeViewModel {
397 viewingEntry = entry 397 viewingEntry = entry
398 viewingObject = result.repository?.object ?? .unknown 398 viewingObject = result.repository?.object ?? .unknown
399 } catch { 399 } catch {
400 self.error = error.localizedDescription 400 self.error = error.userFacingMessage
401 } 401 }
402 } 402 }
403 403
@@ -449,7 +449,7 @@ final class FileTreeViewModel {
449 } 449 }
450 entries = allEntries 450 entries = allEntries
451 } catch { 451 } catch {
452 self.error = error.localizedDescription 452 self.error = error.userFacingMessage
453 } 453 }
454 } 454 }
455 } 455 }
Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift +5 −5
@@ -344,7 +344,7 @@ final class HgRepositoryDetailViewModel {
344 344
345 summaryLoaded = true 345 summaryLoaded = true
346 } catch { 346 } catch {
347 self.error = error.localizedDescription 347 self.error = error.userFacingMessage
348 } 348 }
349 } 349 }
350 350
@@ -367,7 +367,7 @@ final class HgRepositoryDetailViewModel {
367 logCursor = nil 367 logCursor = nil
368 hasMoreLog = false 368 hasMoreLog = false
369 } else { 369 } else {
370 self.error = error.localizedDescription 370 self.error = error.userFacingMessage
371 } 371 }
372 } 372 }
373 } 373 }
@@ -389,7 +389,7 @@ final class HgRepositoryDetailViewModel {
389 logCursor = page.cursor 389 logCursor = page.cursor
390 hasMoreLog = page.cursor != nil 390 hasMoreLog = page.cursor != nil
391 } catch { 391 } catch {
392 self.error = error.localizedDescription 392 self.error = error.userFacingMessage
393 } 393 }
394 } 394 }
395 395
@@ -470,7 +470,7 @@ final class HgRepositoryDetailViewModel {
470 await loadFiles(at: path) 470 await loadFiles(at: path)
471 } 471 }
472 } catch { 472 } catch {
473 self.error = error.localizedDescription 473 self.error = error.userFacingMessage
474 } 474 }
475 } 475 }
476 476
@@ -515,7 +515,7 @@ final class HgRepositoryDetailViewModel {
515 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) 515 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
516 files = [] 516 files = []
517 } else { 517 } else {
518 self.error = error.localizedDescription 518 self.error = error.userFacingMessage
519 } 519 }
520 } 520 }
521 } 521 }
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift +6 −6
@@ -164,7 +164,7 @@ final class HgRepositorySettingsViewModel {
164 editedNonPublishing = repository.nonPublishing ?? false 164 editedNonPublishing = repository.nonPublishing ?? false
165 } 165 }
166 } catch { 166 } catch {
167 self.error = error.localizedDescription 167 self.error = error.userFacingMessage
168 } 168 }
169 } 169 }
170 170
@@ -186,7 +186,7 @@ final class HgRepositorySettingsViewModel {
186 responseType: HgUpdateRepositoryResponse.self 186 responseType: HgUpdateRepositoryResponse.self
187 ) 187 )
188 } catch { 188 } catch {
189 self.error = error.localizedDescription 189 self.error = error.userFacingMessage
190 } 190 }
191 } 191 }
192 192
@@ -205,7 +205,7 @@ final class HgRepositorySettingsViewModel {
205 ) 205 )
206 acls = result.repository?.accessControlList.results ?? [] 206 acls = result.repository?.accessControlList.results ?? []
207 } catch { 207 } catch {
208 self.error = error.localizedDescription 208 self.error = error.userFacingMessage
209 } 209 }
210 } 210 }
211 211
@@ -239,7 +239,7 @@ final class HgRepositorySettingsViewModel {
239 if message.localizedCaseInsensitiveContains("No such repository or user found") { 239 if message.localizedCaseInsensitiveContains("No such repository or user found") {
240 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first." 240 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
241 } else { 241 } else {
242 self.error = message 242 self.error = error.userFacingMessage
243 } 243 }
244 } 244 }
245 } 245 }
@@ -263,7 +263,7 @@ final class HgRepositorySettingsViewModel {
263 ) 263 )
264 acls.removeAll { $0.id == entry.id } 264 acls.removeAll { $0.id == entry.id }
265 } catch { 265 } catch {
266 self.error = error.localizedDescription 266 self.error = error.userFacingMessage
267 } 267 }
268 } 268 }
269 269
@@ -281,7 +281,7 @@ final class HgRepositorySettingsViewModel {
281 ) 281 )
282 didDelete = true 282 didDelete = true
283 } catch { 283 } catch {
284 self.error = error.localizedDescription 284 self.error = error.userFacingMessage
285 } 285 }
286 } 286 }
287} 287}
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +5 −5
@@ -161,7 +161,7 @@ final class RepositoryDetailViewModel {
161 commitCursor = page.cursor 161 commitCursor = page.cursor
162 hasMoreCommits = page.cursor != nil 162 hasMoreCommits = page.cursor != nil
163 } catch { 163 } catch {
164 self.error = error.localizedDescription 164 self.error = error.userFacingMessage
165 } 165 }
166 166
167 isLoadingCommits = false 167 isLoadingCommits = false
@@ -183,7 +183,7 @@ final class RepositoryDetailViewModel {
183 commitCursor = page.cursor 183 commitCursor = page.cursor
184 hasMoreCommits = page.cursor != nil 184 hasMoreCommits = page.cursor != nil
185 } catch { 185 } catch {
186 self.error = error.localizedDescription 186 self.error = error.userFacingMessage
187 } 187 }
188 188
189 isLoadingMoreCommits = false 189 isLoadingMoreCommits = false
@@ -243,7 +243,7 @@ final class RepositoryDetailViewModel {
243 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } 243 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
244 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } 244 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
245 } catch { 245 } catch {
246 self.error = error.localizedDescription 246 self.error = error.userFacingMessage
247 } 247 }
248 248
249 isLoadingRefs = false 249 isLoadingRefs = false
@@ -337,7 +337,7 @@ final class RepositoryDetailViewModel {
337 readmePath = nil 337 readmePath = nil
338 readmeLoaded = true 338 readmeLoaded = true
339 } catch { 339 } catch {
340 self.error = error.localizedDescription 340 self.error = error.userFacingMessage
341 } 341 }
342 } 342 }
343 343
@@ -389,7 +389,7 @@ final class RepositoryDetailViewModel {
389 .filter { !$0.artifacts.results.isEmpty } 389 .filter { !$0.artifacts.results.isEmpty }
390 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) } 390 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
391 } catch { 391 } catch {
392 self.error = error.localizedDescription 392 self.error = error.userFacingMessage
393 } 393 }
394 394
395 isLoadingArtifacts = false 395 isLoadingArtifacts = false
Hutch/Views/Repositories/RepositoryListView.swift +1
@@ -24,6 +24,7 @@ struct RepositoryListView: View {
24 } label: { 24 } label: {
25 Image(systemName: "plus") 25 Image(systemName: "plus")
26 } 26 }
27 .accessibilityLabel("Create repository")
27 } 28 }
28 } 29 }
29 } 30 }
Hutch/Views/Repositories/RepositoryListViewModel.swift +2 −15
@@ -189,7 +189,7 @@ final class RepositoryListViewModel {
189 } catch { 189 } catch {
190 // Only show error if we have no cached data to fall back on 190 // Only show error if we have no cached data to fall back on
191 if repositories.isEmpty { 191 if repositories.isEmpty {
192 self.error = error.localizedDescription 192 self.error = error.userFacingMessage
193 } 193 }
194 } 194 }
195 195
@@ -272,20 +272,7 @@ final class RepositoryListViewModel {
272 } 272 }
273 273
274 private func repositoryCreationErrorMessage(for error: Error) -> String { 274 private func repositoryCreationErrorMessage(for error: Error) -> String {
275 let message: String 275 "Couldn’t create the repository. \(error.userFacingMessage)"
276
277 if let srhtError = error as? SRHTError {
278 switch srhtError {
279 case .graphQLErrors(let errors):
280 message = errors.map(\.message).joined(separator: "\n")
281 default:
282 message = srhtError.localizedDescription
283 }
284 } else {
285 message = error.localizedDescription
286 }
287
288 return "Couldn’t create the repository. \(message)"
289 } 276 }
290 277
291 /// Fetch ALL repositories by paginating through all available pages. 278 /// Fetch ALL repositories by paginating through all available pages.
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +6 −6
@@ -173,7 +173,7 @@ final class RepositorySettingsViewModel {
173 responseType: UpdateRepoInfoResponse.self 173 responseType: UpdateRepoInfoResponse.self
174 ) 174 )
175 } catch { 175 } catch {
176 self.error = error.localizedDescription 176 self.error = error.userFacingMessage
177 } 177 }
178 } 178 }
179 179
@@ -196,7 +196,7 @@ final class RepositorySettingsViewModel {
196 ) 196 )
197 updatedName = result.updateRepository.name 197 updatedName = result.updateRepository.name
198 } catch { 198 } catch {
199 self.error = error.localizedDescription 199 self.error = error.userFacingMessage
200 } 200 }
201 } 201 }
202 202
@@ -255,7 +255,7 @@ final class RepositorySettingsViewModel {
255 ) 255 )
256 acls = result.repository?.acls.results ?? [] 256 acls = result.repository?.acls.results ?? []
257 } catch { 257 } catch {
258 self.error = error.localizedDescription 258 self.error = error.userFacingMessage
259 } 259 }
260 } 260 }
261 261
@@ -286,7 +286,7 @@ final class RepositorySettingsViewModel {
286 } 286 }
287 newACLEntity = "" 287 newACLEntity = ""
288 } catch { 288 } catch {
289 self.error = error.localizedDescription 289 self.error = error.userFacingMessage
290 } 290 }
291 } 291 }
292 292
@@ -331,7 +331,7 @@ final class RepositorySettingsViewModel {
331 ) 331 )
332 acls.removeAll { $0.id == entry.id } 332 acls.removeAll { $0.id == entry.id }
333 } catch { 333 } catch {
334 self.error = error.localizedDescription 334 self.error = error.userFacingMessage
335 } 335 }
336 } 336 }
337 337
@@ -357,7 +357,7 @@ final class RepositorySettingsViewModel {
357 ) 357 )
358 didDelete = true 358 didDelete = true
359 } catch { 359 } catch {
360 self.error = error.localizedDescription 360 self.error = error.userFacingMessage
361 } 361 }
362 } 362 }
363} 363}
Hutch/Views/Settings/SettingsViewModel.swift +9 −9
@@ -162,7 +162,7 @@ final class SettingsViewModel {
162 sshKeys = result.me.sshKeys.results 162 sshKeys = result.me.sshKeys.results
163 pgpKeys = result.me.pgpKeys.results 163 pgpKeys = result.me.pgpKeys.results
164 } catch { 164 } catch {
165 self.error = error.localizedDescription 165 self.error = error.userFacingMessage
166 } 166 }
167 167
168 isLoading = false 168 isLoading = false
@@ -207,7 +207,7 @@ final class SettingsViewModel {
207 } 207 }
208 isEditingProfile = false 208 isEditingProfile = false
209 } catch { 209 } catch {
210 self.error = error.localizedDescription 210 self.error = error.userFacingMessage
211 } 211 }
212 212
213 isSavingProfile = false 213 isSavingProfile = false
@@ -252,7 +252,7 @@ final class SettingsViewModel {
252 ) 252 )
253 } 253 }
254 } catch { 254 } catch {
255 self.error = error.localizedDescription 255 self.error = error.userFacingMessage
256 } 256 }
257 257
258 isUploadingAvatar = false 258 isUploadingAvatar = false
@@ -289,7 +289,7 @@ final class SettingsViewModel {
289 ) 289 )
290 } 290 }
291 } catch { 291 } catch {
292 self.error = error.localizedDescription 292 self.error = error.userFacingMessage
293 } 293 }
294 294
295 isUploadingAvatar = false 295 isUploadingAvatar = false
@@ -313,7 +313,7 @@ final class SettingsViewModel {
313 newSSHKey = "" 313 newSSHKey = ""
314 isAddingSSHKey = false 314 isAddingSSHKey = false
315 } catch { 315 } catch {
316 self.error = error.localizedDescription 316 self.error = error.userFacingMessage
317 } 317 }
318 } 318 }
319 319
@@ -329,7 +329,7 @@ final class SettingsViewModel {
329 ) 329 )
330 sshKeys.removeAll { $0.id == key.id } 330 sshKeys.removeAll { $0.id == key.id }
331 } catch { 331 } catch {
332 self.error = error.localizedDescription 332 self.error = error.userFacingMessage
333 } 333 }
334 } 334 }
335 335
@@ -351,7 +351,7 @@ final class SettingsViewModel {
351 newPGPKey = "" 351 newPGPKey = ""
352 isAddingPGPKey = false 352 isAddingPGPKey = false
353 } catch { 353 } catch {
354 self.error = error.localizedDescription 354 self.error = error.userFacingMessage
355 } 355 }
356 } 356 }
357 357
@@ -367,7 +367,7 @@ final class SettingsViewModel {
367 ) 367 )
368 pgpKeys.removeAll { $0.id == key.id } 368 pgpKeys.removeAll { $0.id == key.id }
369 } catch { 369 } catch {
370 self.error = error.localizedDescription 370 self.error = error.userFacingMessage
371 } 371 }
372 } 372 }
373 373
@@ -385,7 +385,7 @@ final class SettingsViewModel {
385 ) 385 )
386 personalAccessTokens = result.personalAccessTokens 386 personalAccessTokens = result.personalAccessTokens
387 } catch { 387 } catch {
388 self.error = error.localizedDescription 388 self.error = error.userFacingMessage
389 } 389 }
390 390
391 isLoadingPATs = false 391 isLoadingPATs = false
Hutch/Views/Tickets/TicketDetailView.swift +2 −6
@@ -100,6 +100,7 @@ struct TicketDetailView: View {
100 } label: { 100 } label: {
101 Image(systemName: "ellipsis.circle") 101 Image(systemName: "ellipsis.circle")
102 } 102 }
103 .accessibilityLabel("Ticket actions")
103 .sheet(isPresented: $showResolveSheet) { 104 .sheet(isPresented: $showResolveSheet) {
104 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet) 105 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
105 .presentationDetents([.medium]) 106 .presentationDetents([.medium])
@@ -311,7 +312,7 @@ struct TicketDetailView: View {
311 } else { 312 } else {
312 MarkdownContentView(markdown: viewModel.commentText) 313 MarkdownContentView(markdown: viewModel.commentText)
313 .frame(minHeight: 80, maxHeight: 200) 314 .frame(minHeight: 80, maxHeight: 200)
314 .clipShape(RoundedRectangle(cornerRadius: 8)) 315 .clipShape(RoundedRectangle(cornerRadius: 8))
315 } 316 }
316 } 317 }
317 318
@@ -359,11 +360,6 @@ private struct MarkdownContentView: View {
359 } 360 }
360 } 361 }
361 .task(id: markdown) { 362 .task(id: markdown) {
362 if renderedHTML != nil {
363 try? await Task.sleep(for: .milliseconds(150))
364 guard !Task.isCancelled else { return }
365 }
366
367 let html = await Task.detached(priority: .userInitiated) { 363 let html = await Task.detached(priority: .userInitiated) {
368 markdownToHTML(markdown) 364 markdownToHTML(markdown)
369 }.value 365 }.value
Hutch/Views/Tickets/TicketDetailViewModel.swift +10 −10
@@ -324,7 +324,7 @@ final class TicketDetailViewModel {
324 ) 324 )
325 events = payload.events.results.sorted(by: Self.timelineOrder) 325 events = payload.events.results.sorted(by: Self.timelineOrder)
326 } catch { 326 } catch {
327 self.error = error.localizedDescription 327 self.error = error.userFacingMessage
328 } 328 }
329 329
330 isLoading = false 330 isLoading = false
@@ -359,7 +359,7 @@ final class TicketDetailViewModel {
359 events.sort(by: Self.timelineOrder) 359 events.sort(by: Self.timelineOrder)
360 commentText = "" 360 commentText = ""
361 } catch { 361 } catch {
362 self.error = error.localizedDescription 362 self.error = error.userFacingMessage
363 } 363 }
364 364
365 isSubmitting = false 365 isSubmitting = false
@@ -393,7 +393,7 @@ final class TicketDetailViewModel {
393 // Re-fetch the ticket to get updated status/resolution 393 // Re-fetch the ticket to get updated status/resolution
394 await loadTicket() 394 await loadTicket()
395 } catch { 395 } catch {
396 self.error = error.localizedDescription 396 self.error = error.userFacingMessage
397 } 397 }
398 398
399 isPerformingAction = false 399 isPerformingAction = false
@@ -427,7 +427,7 @@ final class TicketDetailViewModel {
427 // Reload to reflect the change 427 // Reload to reflect the change
428 await loadTicket() 428 await loadTicket()
429 } catch { 429 } catch {
430 self.error = error.localizedDescription 430 self.error = error.userFacingMessage
431 } 431 }
432 432
433 isPerformingAction = false 433 isPerformingAction = false
@@ -485,7 +485,7 @@ final class TicketDetailViewModel {
485 assignees: currentAssignees, 485 assignees: currentAssignees,
486 labels: currentTicket.labels 486 labels: currentTicket.labels
487 ) 487 )
488 self.error = error.localizedDescription 488 self.error = error.userFacingMessage
489 } 489 }
490 490
491 isPerformingAction = false 491 isPerformingAction = false
@@ -520,7 +520,7 @@ final class TicketDetailViewModel {
520 // Reload to reflect the change 520 // Reload to reflect the change
521 await loadTicket() 521 await loadTicket()
522 } catch { 522 } catch {
523 self.error = error.localizedDescription 523 self.error = error.userFacingMessage
524 } 524 }
525 525
526 isPerformingAction = false 526 isPerformingAction = false
@@ -544,7 +544,7 @@ final class TicketDetailViewModel {
544 ) 544 )
545 await loadTicket() 545 await loadTicket()
546 } catch { 546 } catch {
547 self.error = error.localizedDescription 547 self.error = error.userFacingMessage
548 } 548 }
549 549
550 isPerformingAction = false 550 isPerformingAction = false
@@ -568,7 +568,7 @@ final class TicketDetailViewModel {
568 ) 568 )
569 await loadTicket() 569 await loadTicket()
570 } catch { 570 } catch {
571 self.error = error.localizedDescription 571 self.error = error.userFacingMessage
572 } 572 }
573 573
574 isPerformingAction = false 574 isPerformingAction = false
@@ -587,7 +587,7 @@ final class TicketDetailViewModel {
587 ) 587 )
588 trackerLabels = result.user.tracker.labels.results 588 trackerLabels = result.user.tracker.labels.results
589 } catch { 589 } catch {
590 self.error = error.localizedDescription 590 self.error = error.userFacingMessage
591 } 591 }
592 } 592 }
593 593
@@ -610,7 +610,7 @@ final class TicketDetailViewModel {
610 ) 610 )
611 trackerLabels.append(result.createLabel) 611 trackerLabels.append(result.createLabel)
612 } catch { 612 } catch {
613 self.error = error.localizedDescription 613 self.error = error.userFacingMessage
614 } 614 }
615 615
616 isPerformingAction = false 616 isPerformingAction = false
Hutch/Views/Tickets/TicketListView.swift +1
@@ -33,6 +33,7 @@ struct TicketListView: View {
33 } label: { 33 } label: {
34 Image(systemName: "plus") 34 Image(systemName: "plus")
35 } 35 }
36 .accessibilityLabel("Create ticket")
36 } 37 }
37 } 38 }
38 } 39 }
Hutch/Views/Tickets/TicketListViewModel.swift +3 −3
@@ -121,7 +121,7 @@ final class TicketListViewModel {
121 cursor = page.cursor 121 cursor = page.cursor
122 hasMore = page.cursor != nil 122 hasMore = page.cursor != nil
123 } catch { 123 } catch {
124 self.error = error.localizedDescription 124 self.error = error.userFacingMessage
125 } 125 }
126 126
127 isLoading = false 127 isLoading = false
@@ -143,7 +143,7 @@ final class TicketListViewModel {
143 cursor = page.cursor 143 cursor = page.cursor
144 hasMore = page.cursor != nil 144 hasMore = page.cursor != nil
145 } catch { 145 } catch {
146 self.error = error.localizedDescription 146 self.error = error.userFacingMessage
147 } 147 }
148 148
149 isLoadingMore = false 149 isLoadingMore = false
@@ -185,7 +185,7 @@ final class TicketListViewModel {
185 tickets.insert(ticket, at: 0) 185 tickets.insert(ticket, at: 0)
186 return ticket 186 return ticket
187 } catch { 187 } catch {
188 self.error = "Couldn’t create the ticket. \(error.localizedDescription)" 188 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
189 return nil 189 return nil
190 } 190 }
191 } 191 }
Hutch/Views/Tickets/TrackerListViewModel.swift +3 −16
@@ -78,7 +78,7 @@ final class TrackerListViewModel {
78 cursor = page.cursor 78 cursor = page.cursor
79 hasMore = page.cursor != nil 79 hasMore = page.cursor != nil
80 } catch { 80 } catch {
81 self.error = error.localizedDescription 81 self.error = error.userFacingMessage
82 } 82 }
83 83
84 isLoading = false 84 isLoading = false
@@ -100,7 +100,7 @@ final class TrackerListViewModel {
100 cursor = page.cursor 100 cursor = page.cursor
101 hasMore = page.cursor != nil 101 hasMore = page.cursor != nil
102 } catch { 102 } catch {
103 self.error = error.localizedDescription 103 self.error = error.userFacingMessage
104 } 104 }
105 105
106 isLoadingMore = false 106 isLoadingMore = false
@@ -165,19 +165,6 @@ final class TrackerListViewModel {
165 } 165 }
166 166
167 private func trackerCreationErrorMessage(for error: Error) -> String { 167 private func trackerCreationErrorMessage(for error: Error) -> String {
168 let message: String 168 "Couldn’t create the tracker. \(error.userFacingMessage)"
169
170 if let srhtError = error as? SRHTError {
171 switch srhtError {
172 case .graphQLErrors(let errors):
173 message = errors.map(\.message).joined(separator: "\n")
174 default:
175 message = srhtError.localizedDescription
176 }
177 } else {
178 message = error.localizedDescription
179 }
180
181 return "Couldn’t create the tracker. \(message)"
182 } 169 }
183} 170}