Commit fa8188260f
Unsigned
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 | ||
| 234 | enum MoreDestination: Hashable { | 236 | enum 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 | ||
| 107 | extension 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. |
| 59 | struct GraphQLError: Decodable, Sendable { | 131 | struct 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 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import os | ||
| 3 | |||
| 4 | private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation") | ||
| 5 | 2 | ||
| 6 | struct InboxView: View { | 3 | struct 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 | |||
| 4 | import UIKit | 4 | import UIKit |
| 5 | 5 | ||
| 6 | private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") | 6 | private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") |
| 7 | private let inboxThreadNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxThreadNavigation") | ||
| 8 | 7 | ||
| 9 | struct ThreadDetailView: View { | 8 | struct 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 | } |