Commit 4c312827dd
Unsigned
Layout: unified · split
Hutch/App/RootView.swift +13 −13
| @@ -66,19 +66,6 @@ struct RootView: View { | |||
| 66 | Label("Repositories", systemImage: "book.closed") | 66 | Label("Repositories", systemImage: "book.closed") |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | NavigationStack(path: $buildsPath) { | ||
| 70 | BuildListView() | ||
| 71 | // Int destination used by deep links (hutch://builds/<id>). | ||
| 72 | // JobSummary destination is registered inside BuildListView. | ||
| 73 | .navigationDestination(for: Int.self) { jobId in | ||
| 74 | BuildDetailView(jobId: jobId) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | .tag(AppState.Tab.builds) | ||
| 78 | .tabItem { | ||
| 79 | Label("Builds", systemImage: "hammer") | ||
| 80 | } | ||
| 81 | |||
| 82 | NavigationStack(path: $ticketsPath) { | 69 | NavigationStack(path: $ticketsPath) { |
| 83 | TrackerListView() | 70 | TrackerListView() |
| 84 | // Deep link destination for jumping straight to a ticket. | 71 | // Deep link destination for jumping straight to a ticket. |
| @@ -96,6 +83,19 @@ struct RootView: View { | |||
| 96 | .tabItem { | 83 | .tabItem { |
| 97 | Label("Settings", systemImage: "gear") | 84 | Label("Settings", systemImage: "gear") |
| 98 | } | 85 | } |
| 86 | |||
| 87 | NavigationStack(path: $buildsPath) { | ||
| 88 | BuildListView() | ||
| 89 | // Int destination used by deep links (hutch://builds/<id>). | ||
| 90 | // JobSummary destination is registered inside BuildListView. | ||
| 91 | .navigationDestination(for: Int.self) { jobId in | ||
| 92 | BuildDetailView(jobId: jobId) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | .tag(AppState.Tab.builds) | ||
| 96 | .tabItem { | ||
| 97 | Label("Builds", systemImage: "hammer") | ||
| 98 | } | ||
| 99 | } | 99 | } |
| 100 | .overlay { | 100 | .overlay { |
| 101 | if isResolvingDeepLink { | 101 | if isResolvingDeepLink { |
Hutch/Models/Inbox.swift +6
| @@ -178,6 +178,12 @@ enum InboxReadStateStore { | |||
| 178 | defaults.set(dictionary, forKey: key) | 178 | defaults.set(dictionary, forKey: key) |
| 179 | } | 179 | } |
| 180 | 180 | ||
| 181 | static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { | ||
| 182 | var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] | ||
| 183 | dictionary.removeValue(forKey: threadID) | ||
| 184 | defaults.set(dictionary, forKey: key) | ||
| 185 | } | ||
| 186 | |||
| 181 | static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { | 187 | static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { |
| 182 | guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { | 188 | guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { |
| 183 | return true | 189 | return true |
Hutch/Views/Inbox/InboxView.swift +22 −1
| @@ -31,6 +31,12 @@ struct InboxView: View { | |||
| 31 | NavigationLink(value: thread) { | 31 | NavigationLink(value: thread) { |
| 32 | InboxThreadRow(thread: thread) | 32 | InboxThreadRow(thread: thread) |
| 33 | } | 33 | } |
| 34 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 35 | readStateAction(for: thread, in: viewModel) | ||
| 36 | } | ||
| 37 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 38 | readStateAction(for: thread, in: viewModel) | ||
| 39 | } | ||
| 34 | } | 40 | } |
| 35 | } | 41 | } |
| 36 | .listStyle(.plain) | 42 | .listStyle(.plain) |
| @@ -39,7 +45,7 @@ struct InboxView: View { | |||
| 39 | SRHTLoadingStateView(message: "Loading inbox…") | 45 | SRHTLoadingStateView(message: "Loading inbox…") |
| 40 | } else if let error = viewModel.error, viewModel.threads.isEmpty { | 46 | } else if let error = viewModel.error, viewModel.threads.isEmpty { |
| 41 | SRHTErrorStateView( | 47 | SRHTErrorStateView( |
| 42 | title: "Couldn't Load Threads", | 48 | title: "Failed to load inbox", |
| 43 | message: error, | 49 | message: error, |
| 44 | retryAction: { await viewModel.loadThreads() } | 50 | retryAction: { await viewModel.loadThreads() } |
| 45 | ) | 51 | ) |
| @@ -64,6 +70,21 @@ struct InboxView: View { | |||
| 64 | } | 70 | } |
| 65 | } | 71 | } |
| 66 | } | 72 | } |
| 73 | |||
| 74 | @ViewBuilder | ||
| 75 | private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { | ||
| 76 | Button { | ||
| 77 | withAnimation(.easeInOut(duration: 0.2)) { | ||
| 78 | viewModel.toggleThreadReadState(thread) | ||
| 79 | } | ||
| 80 | } label: { | ||
| 81 | Label( | ||
| 82 | thread.isUnread ? "Mark as Read" : "Mark as Unread", | ||
| 83 | systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" | ||
| 84 | ) | ||
| 85 | } | ||
| 86 | .tint(thread.isUnread ? .blue : .gray) | ||
| 87 | } | ||
| 67 | } | 88 | } |
| 68 | 89 | ||
| 69 | private struct InboxThreadRow: View { | 90 | private struct InboxThreadRow: View { |
Hutch/Views/Inbox/InboxViewModel.swift +53 −23
| @@ -134,33 +134,34 @@ final class InboxViewModel { | |||
| 134 | return lhs.lastActivityAt > rhs.lastActivityAt | 134 | return lhs.lastActivityAt > rhs.lastActivityAt |
| 135 | } | 135 | } |
| 136 | } catch { | 136 | } catch { |
| 137 | threads = [] | 137 | inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)") |
| 138 | self.error = error.localizedDescription | 138 | self.error = "Failed to load inbox" |
| 139 | } | 139 | } |
| 140 | } | 140 | } |
| 141 | 141 | ||
| 142 | func markThreadRead(_ thread: InboxThreadSummary) { | 142 | func markThreadRead(_ thread: InboxThreadSummary) { |
| 143 | let viewedAt = max(Date(), thread.lastActivityAt) | 143 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 144 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | 144 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) |
| 145 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } | 145 | inboxListLogger.debug( |
| 146 | let current = threads[index] | 146 | "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" |
| 147 | threads[index] = InboxThreadSummary( | ||
| 148 | rootEmailID: current.rootEmailID, | ||
| 149 | rootMessageID: current.rootMessageID, | ||
| 150 | threadRootEmailIDs: current.threadRootEmailIDs, | ||
| 151 | threadRootMessageIDs: current.threadRootMessageIDs, | ||
| 152 | listID: current.listID, | ||
| 153 | listRID: current.listRID, | ||
| 154 | listName: current.listName, | ||
| 155 | listOwner: current.listOwner, | ||
| 156 | subject: current.subject, | ||
| 157 | latestSender: current.latestSender, | ||
| 158 | lastActivityAt: current.lastActivityAt, | ||
| 159 | messageCount: current.messageCount, | ||
| 160 | repo: current.repo, | ||
| 161 | containsPatch: current.containsPatch, | ||
| 162 | isUnread: false | ||
| 163 | ) | 147 | ) |
| 148 | updateThread(thread, isUnread: false) | ||
| 149 | } | ||
| 150 | |||
| 151 | func markThreadUnread(_ thread: InboxThreadSummary) { | ||
| 152 | InboxReadStateStore.markUnread(for: thread.id) | ||
| 153 | inboxListLogger.debug( | ||
| 154 | "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil" | ||
| 155 | ) | ||
| 156 | updateThread(thread, isUnread: true) | ||
| 157 | } | ||
| 158 | |||
| 159 | func toggleThreadReadState(_ thread: InboxThreadSummary) { | ||
| 160 | if thread.isUnread { | ||
| 161 | markThreadRead(thread) | ||
| 162 | } else { | ||
| 163 | markThreadUnread(thread) | ||
| 164 | } | ||
| 164 | } | 165 | } |
| 165 | 166 | ||
| 166 | private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { | 167 | private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { |
| @@ -211,7 +212,7 @@ final class InboxViewModel { | |||
| 211 | do { | 212 | do { |
| 212 | return (try await self.fetchThreads(for: mailingList), nil) | 213 | return (try await self.fetchThreads(for: mailingList), nil) |
| 213 | } catch { | 214 | } catch { |
| 214 | return ([], error.localizedDescription) | 215 | return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)") |
| 215 | } | 216 | } |
| 216 | } | 217 | } |
| 217 | } | 218 | } |
| @@ -229,6 +230,9 @@ final class InboxViewModel { | |||
| 229 | 230 | ||
| 230 | summaries.append(contentsOf: batchResult.0) | 231 | summaries.append(contentsOf: batchResult.0) |
| 231 | failureMessages.append(contentsOf: batchResult.1) | 232 | failureMessages.append(contentsOf: batchResult.1) |
| 233 | for failure in batchResult.1 { | ||
| 234 | inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)") | ||
| 235 | } | ||
| 232 | startIndex = endIndex | 236 | startIndex = endIndex |
| 233 | } | 237 | } |
| 234 | 238 | ||
| @@ -248,11 +252,15 @@ final class InboxViewModel { | |||
| 248 | ) | 252 | ) |
| 249 | 253 | ||
| 250 | return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in | 254 | return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in |
| 251 | let threadID = "\(mailingList.rid)#\(thread.root.messageID)" | ||
| 252 | let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())" | 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())" |
| 256 | let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey) | ||
| 257 | let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated) | ||
| 253 | inboxListLogger.debug( | 258 | inboxListLogger.debug( |
| 254 | "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)" | 259 | "Inbox thread grouping candidate: listRID=\(mailingList.rid, privacy: .public) rootMessageID=\(thread.root.messageID, privacy: .public) rootEmailID=\(thread.root.id, privacy: .public) groupingKey=\(groupingKey, privacy: .public)" |
| 255 | ) | 260 | ) |
| 261 | inboxListLogger.debug( | ||
| 262 | "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)" | ||
| 263 | ) | ||
| 256 | return InboxThreadSummary( | 264 | return InboxThreadSummary( |
| 257 | rootEmailID: thread.root.id, | 265 | rootEmailID: thread.root.id, |
| 258 | rootMessageID: thread.root.messageID, | 266 | rootMessageID: thread.root.messageID, |
| @@ -268,7 +276,7 @@ final class InboxViewModel { | |||
| 268 | messageCount: thread.replies + 1, | 276 | messageCount: thread.replies + 1, |
| 269 | repo: Self.deriveRepositoryName(from: mailingList.name), | 277 | repo: Self.deriveRepositoryName(from: mailingList.name), |
| 270 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), | 278 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), |
| 271 | isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | 279 | isUnread: isUnread |
| 272 | ) | 280 | ) |
| 273 | } | 281 | } |
| 274 | } | 282 | } |
| @@ -318,6 +326,28 @@ final class InboxViewModel { | |||
| 318 | } | 326 | } |
| 319 | } | 327 | } |
| 320 | 328 | ||
| 329 | private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { | ||
| 330 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } | ||
| 331 | let current = threads[index] | ||
| 332 | threads[index] = InboxThreadSummary( | ||
| 333 | rootEmailID: current.rootEmailID, | ||
| 334 | rootMessageID: current.rootMessageID, | ||
| 335 | threadRootEmailIDs: current.threadRootEmailIDs, | ||
| 336 | threadRootMessageIDs: current.threadRootMessageIDs, | ||
| 337 | listID: current.listID, | ||
| 338 | listRID: current.listRID, | ||
| 339 | listName: current.listName, | ||
| 340 | listOwner: current.listOwner, | ||
| 341 | subject: current.subject, | ||
| 342 | latestSender: current.latestSender, | ||
| 343 | lastActivityAt: current.lastActivityAt, | ||
| 344 | messageCount: current.messageCount, | ||
| 345 | repo: current.repo, | ||
| 346 | containsPatch: current.containsPatch, | ||
| 347 | isUnread: isUnread | ||
| 348 | ) | ||
| 349 | } | ||
| 350 | |||
| 321 | private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { | 351 | private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { |
| 322 | var seen = Set<String>() | 352 | var seen = Set<String>() |
| 323 | return mailingLists.filter { mailingList in | 353 | return mailingLists.filter { mailingList in |
Hutch/Views/Inbox/ThreadDetailView.swift +9 −1
| @@ -93,6 +93,14 @@ struct ThreadDetailView: View { | |||
| 93 | .padding(.vertical, 4) | 93 | .padding(.vertical, 4) |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | if let partialWarning = viewModel.partialWarning { | ||
| 97 | Section { | ||
| 98 | Text(partialWarning) | ||
| 99 | .font(.caption) | ||
| 100 | .foregroundStyle(.secondary) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 96 | ForEach(thread.messages) { message in | 104 | ForEach(thread.messages) { message in |
| 97 | InboxMessageRow(message: message) | 105 | InboxMessageRow(message: message) |
| 98 | } | 106 | } |
| @@ -111,7 +119,7 @@ struct ThreadDetailView: View { | |||
| 111 | SRHTLoadingStateView(message: "Loading thread…") | 119 | SRHTLoadingStateView(message: "Loading thread…") |
| 112 | } else if let error = viewModel.error, viewModel.thread == nil { | 120 | } else if let error = viewModel.error, viewModel.thread == nil { |
| 113 | SRHTErrorStateView( | 121 | SRHTErrorStateView( |
| 114 | title: "Couldn't Load Thread", | 122 | title: "Failed to load thread", |
| 115 | message: error, | 123 | message: error, |
| 116 | retryAction: { await viewModel.loadThread() } | 124 | retryAction: { await viewModel.loadThread() } |
| 117 | ) | 125 | ) |
Hutch/Views/Inbox/ThreadViewModel.swift +87 −29
| @@ -61,6 +61,7 @@ final class ThreadViewModel { | |||
| 61 | private(set) var thread: InboxThreadDetail? | 61 | private(set) var thread: InboxThreadDetail? |
| 62 | private(set) var isLoading = false | 62 | private(set) var isLoading = false |
| 63 | var error: String? | 63 | var error: String? |
| 64 | var partialWarning: String? | ||
| 64 | var composeDraft: MailComposeDraft? | 65 | var composeDraft: MailComposeDraft? |
| 65 | 66 | ||
| 66 | private let summary: InboxThreadSummary | 67 | private let summary: InboxThreadSummary |
| @@ -168,6 +169,7 @@ final class ThreadViewModel { | |||
| 168 | guard !isLoading else { return } | 169 | guard !isLoading else { return } |
| 169 | isLoading = true | 170 | isLoading = true |
| 170 | error = nil | 171 | error = nil |
| 172 | partialWarning = nil | ||
| 171 | defer { isLoading = false } | 173 | defer { isLoading = false } |
| 172 | 174 | ||
| 173 | inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") | 175 | inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") |
| @@ -187,18 +189,27 @@ final class ThreadViewModel { | |||
| 187 | ) | 189 | ) |
| 188 | var messagesByID: [Int: InboxMessage] = [:] | 190 | var messagesByID: [Int: InboxMessage] = [:] |
| 189 | 191 | ||
| 192 | var hadPartialReplyFailure = false | ||
| 193 | |||
| 190 | for payload in threadPayloads { | 194 | for payload in threadPayloads { |
| 191 | guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { | 195 | guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { |
| 192 | continue | 196 | continue |
| 193 | } | 197 | } |
| 194 | messagesByID[rootMessage.id] = rootMessage | 198 | messagesByID[rootMessage.id] = rootMessage |
| 195 | 199 | ||
| 196 | let descendantMessages = try await fetchAllDescendantMessages( | 200 | do { |
| 197 | initialPayload: payload, | 201 | let descendantMessages = try await fetchAllDescendantMessages( |
| 198 | candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) | 202 | initialPayload: payload, |
| 199 | ) | 203 | candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) |
| 200 | for message in descendantMessages { | 204 | ) |
| 201 | messagesByID[message.id] = message | 205 | for message in descendantMessages { |
| 206 | messagesByID[message.id] = message | ||
| 207 | } | ||
| 208 | } catch { | ||
| 209 | hadPartialReplyFailure = true | ||
| 210 | inboxLogger.error( | ||
| 211 | "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)" | ||
| 212 | ) | ||
| 202 | } | 213 | } |
| 203 | } | 214 | } |
| 204 | 215 | ||
| @@ -223,9 +234,15 @@ final class ThreadViewModel { | |||
| 223 | messageCount: max(messages.count, summary.messageCount ?? 0), | 234 | messageCount: max(messages.count, summary.messageCount ?? 0), |
| 224 | messages: messages | 235 | messages: messages |
| 225 | ) | 236 | ) |
| 237 | if hadPartialReplyFailure { | ||
| 238 | partialWarning = "Some replies could not be loaded." | ||
| 239 | } | ||
| 226 | } catch { | 240 | } catch { |
| 227 | thread = nil | 241 | if thread == nil { |
| 228 | self.error = error.localizedDescription | 242 | self.error = "Failed to load thread" |
| 243 | } else { | ||
| 244 | self.error = error.localizedDescription | ||
| 245 | } | ||
| 229 | inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") | 246 | inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") |
| 230 | } | 247 | } |
| 231 | } | 248 | } |
| @@ -318,18 +335,27 @@ final class ThreadViewModel { | |||
| 318 | variables["cursor"] = threadCursor | 335 | variables["cursor"] = threadCursor |
| 319 | } | 336 | } |
| 320 | 337 | ||
| 321 | let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest( | 338 | let response: InboxThreadDetailResponse |
| 322 | client: client, | 339 | do { |
| 323 | query: Self.threadDetailQuery, | 340 | response = try await Self.executeGraphQLRequest( |
| 324 | variables: { | 341 | client: client, |
| 325 | var variables = variables | 342 | query: Self.threadDetailQuery, |
| 326 | variables["descCursor"] = nil as String? | 343 | variables: { |
| 327 | return variables | 344 | var variables = variables |
| 328 | }() | 345 | variables["descCursor"] = nil as String? |
| 329 | ) | 346 | return variables |
| 347 | }() | ||
| 348 | ) | ||
| 349 | } catch { | ||
| 350 | if Self.isRecoverableNoRows(error) { | ||
| 351 | inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") | ||
| 352 | return nil | ||
| 353 | } | ||
| 354 | throw error | ||
| 355 | } | ||
| 330 | 356 | ||
| 331 | guard let threadPage = response.list?.threads else { | 357 | guard let threadPage = response.list?.threads else { |
| 332 | throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) | 358 | return nil |
| 333 | } | 359 | } |
| 334 | 360 | ||
| 335 | let candidates = threadPage.results.map { payload in | 361 | let candidates = threadPage.results.map { payload in |
| @@ -389,15 +415,26 @@ final class ThreadViewModel { | |||
| 389 | candidateMessageIDs: [String] | 415 | candidateMessageIDs: [String] |
| 390 | ) async throws -> InboxThreadMessagesPage? { | 416 | ) async throws -> InboxThreadMessagesPage? { |
| 391 | for messageID in candidateMessageIDs { | 417 | for messageID in candidateMessageIDs { |
| 392 | let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( | 418 | let response: InboxThreadLookupResponse |
| 393 | client: client, | 419 | do { |
| 394 | query: Self.threadByMessageIDQuery, | 420 | response = try await Self.executeGraphQLRequest( |
| 395 | variables: [ | 421 | client: client, |
| 396 | "rid": summary.listRID, | 422 | query: Self.threadByMessageIDQuery, |
| 397 | "messageID": messageID, | 423 | variables: [ |
| 398 | "descCursor": cursor | 424 | "rid": summary.listRID, |
| 399 | ] | 425 | "messageID": messageID, |
| 400 | ) | 426 | "descCursor": cursor |
| 427 | ] | ||
| 428 | ) | ||
| 429 | } catch { | ||
| 430 | if Self.isRecoverableNoRows(error) { | ||
| 431 | inboxLogger.error( | ||
| 432 | "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)" | ||
| 433 | ) | ||
| 434 | continue | ||
| 435 | } | ||
| 436 | throw error | ||
| 437 | } | ||
| 401 | 438 | ||
| 402 | if let descendants = response.list?.message?.thread?.descendants { | 439 | if let descendants = response.list?.message?.thread?.descendants { |
| 403 | return descendants | 440 | return descendants |
| @@ -533,7 +570,8 @@ final class ThreadViewModel { | |||
| 533 | } | 570 | } |
| 534 | 571 | ||
| 535 | private static func sanitizedDisplayBody(from body: String) -> String { | 572 | private static func sanitizedDisplayBody(from body: String) -> String { |
| 536 | let lines = body.components(separatedBy: .newlines) | 573 | let normalizedBody = normalizeLineEndings(in: body) |
| 574 | let lines = normalizedBody.components(separatedBy: "\n") | ||
| 537 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] | 575 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] |
| 538 | var headerCount = 0 | 576 | var headerCount = 0 |
| 539 | var blankLineIndex: Int? | 577 | var blankLineIndex: Int? |
| @@ -551,7 +589,7 @@ final class ThreadViewModel { | |||
| 551 | } | 589 | } |
| 552 | 590 | ||
| 553 | guard headerCount >= 2, let blankLineIndex else { | 591 | guard headerCount >= 2, let blankLineIndex else { |
| 554 | return body | 592 | return stripLeadingFromLineIfPresent(in: normalizedBody) |
| 555 | } | 593 | } |
| 556 | 594 | ||
| 557 | return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") | 595 | return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") |
| @@ -644,6 +682,19 @@ final class ThreadViewModel { | |||
| 644 | .replacingOccurrences(of: "\r", with: "\n") | 682 | .replacingOccurrences(of: "\r", with: "\n") |
| 645 | } | 683 | } |
| 646 | 684 | ||
| 685 | private static func stripLeadingFromLineIfPresent(in body: String) -> String { | ||
| 686 | let lines = body.components(separatedBy: "\n") | ||
| 687 | guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { | ||
| 688 | return body | ||
| 689 | } | ||
| 690 | |||
| 691 | var remainingLines = Array(lines.dropFirst()) | ||
| 692 | if let nextLine = remainingLines.first, nextLine.isEmpty { | ||
| 693 | remainingLines.removeFirst() | ||
| 694 | } | ||
| 695 | return remainingLines.joined(separator: "\n") | ||
| 696 | } | ||
| 697 | |||
| 647 | private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { | 698 | private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { |
| 648 | let prefix = "\(headerName):" | 699 | let prefix = "\(headerName):" |
| 649 | let lines = body.components(separatedBy: .newlines) | 700 | let lines = body.components(separatedBy: .newlines) |
| @@ -699,4 +750,11 @@ final class ThreadViewModel { | |||
| 699 | } | 750 | } |
| 700 | return payload | 751 | return payload |
| 701 | } | 752 | } |
| 753 | |||
| 754 | private static func isRecoverableNoRows(_ error: Error) -> Bool { | ||
| 755 | guard case let SRHTError.graphQLErrors(errors) = error else { | ||
| 756 | return false | ||
| 757 | } | ||
| 758 | return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") } | ||
| 759 | } | ||
| 702 | } | 760 | } |
Hutch/Views/Repositories/DiffView.swift +141 −6
| @@ -9,8 +9,73 @@ struct DiffView: View { | |||
| 9 | let diff: String | 9 | let diff: String |
| 10 | 10 | ||
| 11 | var body: some View { | 11 | var body: some View { |
| 12 | let lines = normalizedDiff.components(separatedBy: "\n") | 12 | VStack(alignment: .leading, spacing: 12) { |
| 13 | ForEach(fileSections) { section in | ||
| 14 | DiffFileSectionView(section: section) | ||
| 15 | } | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | private var fileSections: [DiffFileSection] { | ||
| 20 | DiffFileSection.parse(from: normalizedDiff) | ||
| 21 | } | ||
| 13 | 22 | ||
| 23 | private var normalizedDiff: String { | ||
| 24 | diff | ||
| 25 | .replacingOccurrences(of: "\r\n", with: "\n") | ||
| 26 | .replacingOccurrences(of: "\r", with: "\n") | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | private struct DiffFileSectionView: View { | ||
| 31 | let section: DiffFileSection | ||
| 32 | @State private var isExpanded = true | ||
| 33 | |||
| 34 | var body: some View { | ||
| 35 | VStack(alignment: .leading, spacing: 0) { | ||
| 36 | Button { | ||
| 37 | isExpanded.toggle() | ||
| 38 | } label: { | ||
| 39 | HStack(spacing: 10) { | ||
| 40 | Image(systemName: isExpanded ? "chevron.down" : "chevron.right") | ||
| 41 | .font(.caption.weight(.semibold)) | ||
| 42 | .foregroundStyle(.secondary) | ||
| 43 | .frame(width: 12) | ||
| 44 | |||
| 45 | Text(section.filename) | ||
| 46 | .font(.subheadline.weight(.semibold)) | ||
| 47 | .foregroundStyle(.primary) | ||
| 48 | .lineLimit(1) | ||
| 49 | |||
| 50 | Spacer(minLength: 8) | ||
| 51 | |||
| 52 | Text(section.changeSummary) | ||
| 53 | .font(.caption.weight(.medium)) | ||
| 54 | .foregroundStyle(.secondary) | ||
| 55 | } | ||
| 56 | .padding(.horizontal, 10) | ||
| 57 | .padding(.vertical, 8) | ||
| 58 | .contentShape(Rectangle()) | ||
| 59 | } | ||
| 60 | .buttonStyle(.plain) | ||
| 61 | .background(Color(.tertiarySystemBackground)) | ||
| 62 | |||
| 63 | if isExpanded { | ||
| 64 | DiffBlockView(lines: section.lines) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) | ||
| 68 | .overlay { | ||
| 69 | RoundedRectangle(cornerRadius: 8, style: .continuous) | ||
| 70 | .strokeBorder(Color.primary.opacity(0.06)) | ||
| 71 | } | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | private struct DiffBlockView: View { | ||
| 76 | let lines: [String] | ||
| 77 | |||
| 78 | var body: some View { | ||
| 14 | VStack(alignment: .leading, spacing: 0) { | 79 | VStack(alignment: .leading, spacing: 0) { |
| 15 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in | 80 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in |
| 16 | DiffLineView(line: line) | 81 | DiffLineView(line: line) |
| @@ -19,13 +84,83 @@ struct DiffView: View { | |||
| 19 | .font(.system(.caption, design: .monospaced)) | 84 | .font(.system(.caption, design: .monospaced)) |
| 20 | .frame(maxWidth: .infinity, alignment: .leading) | 85 | .frame(maxWidth: .infinity, alignment: .leading) |
| 21 | .background(Color(.secondarySystemBackground)) | 86 | .background(Color(.secondarySystemBackground)) |
| 22 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) | ||
| 23 | } | 87 | } |
| 88 | } | ||
| 24 | 89 | ||
| 25 | private var normalizedDiff: String { | 90 | private struct DiffFileSection: Identifiable { |
| 26 | diff | 91 | let id: String |
| 27 | .replacingOccurrences(of: "\r\n", with: "\n") | 92 | let filename: String |
| 28 | .replacingOccurrences(of: "\r", with: "\n") | 93 | let lines: [String] |
| 94 | let additions: Int | ||
| 95 | let deletions: Int | ||
| 96 | |||
| 97 | var changeSummary: String { | ||
| 98 | "+\(additions) -\(deletions)" | ||
| 99 | } | ||
| 100 | |||
| 101 | static func parse(from diff: String) -> [DiffFileSection] { | ||
| 102 | let lines = diff.components(separatedBy: "\n") | ||
| 103 | guard !lines.isEmpty else { return [] } | ||
| 104 | |||
| 105 | let boundaries = lines.enumerated().compactMap { index, line in | ||
| 106 | line.hasPrefix("diff --git ") ? index : nil | ||
| 107 | } | ||
| 108 | |||
| 109 | guard !boundaries.isEmpty else { | ||
| 110 | let section = makeSection(lines: lines, fallbackIndex: 0) | ||
| 111 | return section.lines.isEmpty ? [] : [section] | ||
| 112 | } | ||
| 113 | |||
| 114 | var sections: [DiffFileSection] = [] | ||
| 115 | for (position, startIndex) in boundaries.enumerated() { | ||
| 116 | let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count | ||
| 117 | let sectionLines = Array(lines[startIndex..<endIndex]) | ||
| 118 | let section = makeSection(lines: sectionLines, fallbackIndex: position) | ||
| 119 | if !section.lines.isEmpty { | ||
| 120 | sections.append(section) | ||
| 121 | } | ||
| 122 | } | ||
| 123 | return sections | ||
| 124 | } | ||
| 125 | |||
| 126 | private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection { | ||
| 127 | let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)" | ||
| 128 | let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count | ||
| 129 | let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count | ||
| 130 | return DiffFileSection( | ||
| 131 | id: "\(fallbackIndex)-\(filename)", | ||
| 132 | filename: filename, | ||
| 133 | lines: lines, | ||
| 134 | additions: additions, | ||
| 135 | deletions: deletions | ||
| 136 | ) | ||
| 137 | } | ||
| 138 | |||
| 139 | private static func fileName(from lines: [String]) -> String? { | ||
| 140 | if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) { | ||
| 141 | let parts = diffHeader.split(separator: " ") | ||
| 142 | if let rhs = parts.last, rhs.hasPrefix("b/") { | ||
| 143 | return String(rhs.dropFirst(2)) | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) { | ||
| 148 | let path = String(plusHeader.dropFirst(4)) | ||
| 149 | if path.hasPrefix("b/") { | ||
| 150 | return String(path.dropFirst(2)) | ||
| 151 | } | ||
| 152 | return path | ||
| 153 | } | ||
| 154 | |||
| 155 | if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) { | ||
| 156 | let path = String(minusHeader.dropFirst(4)) | ||
| 157 | if path.hasPrefix("a/") { | ||
| 158 | return String(path.dropFirst(2)) | ||
| 159 | } | ||
| 160 | return path | ||
| 161 | } | ||
| 162 | |||
| 163 | return nil | ||
| 29 | } | 164 | } |
| 30 | } | 165 | } |
| 31 | 166 | ||
Hutch/Views/Tickets/TicketDetailView.swift +47 −4
| @@ -132,7 +132,7 @@ struct TicketDetailView: View { | |||
| 132 | ScrollView { | 132 | ScrollView { |
| 133 | VStack(alignment: .leading, spacing: 0) { | 133 | VStack(alignment: .leading, spacing: 0) { |
| 134 | // Header | 134 | // Header |
| 135 | ticketHeader(ticket) | 135 | ticketHeader(ticket, viewModel: viewModel) |
| 136 | 136 | ||
| 137 | Divider() | 137 | Divider() |
| 138 | .padding(.vertical, 12) | 138 | .padding(.vertical, 12) |
| @@ -186,10 +186,16 @@ struct TicketDetailView: View { | |||
| 186 | // MARK: - Header | 186 | // MARK: - Header |
| 187 | 187 | ||
| 188 | @ViewBuilder | 188 | @ViewBuilder |
| 189 | private func ticketHeader(_ ticket: TicketDetail) -> some View { | 189 | private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { |
| 190 | VStack(alignment: .leading, spacing: 8) { | 190 | VStack(alignment: .leading, spacing: 8) { |
| 191 | Text(ticket.title) | 191 | HStack(alignment: .top, spacing: 12) { |
| 192 | .font(.title3.weight(.semibold)) | 192 | Text(ticket.title) |
| 193 | .font(.title3.weight(.semibold)) | ||
| 194 | |||
| 195 | Spacer(minLength: 12) | ||
| 196 | |||
| 197 | assignToMeButton(ticket: ticket, viewModel: viewModel) | ||
| 198 | } | ||
| 193 | 199 | ||
| 194 | HStack(spacing: 8) { | 200 | HStack(spacing: 8) { |
| 195 | TicketStatusIcon(status: ticket.status) | 201 | TicketStatusIcon(status: ticket.status) |
| @@ -235,6 +241,43 @@ struct TicketDetailView: View { | |||
| 235 | .padding() | 241 | .padding() |
| 236 | } | 242 | } |
| 237 | 243 | ||
| 244 | @ViewBuilder | ||
| 245 | private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { | ||
| 246 | if let currentUser = appState.currentUser { | ||
| 247 | let isAssignedToCurrentUser = ticket.assignees.contains { | ||
| 248 | TicketDetailViewModel.matchesAssignee($0, user: currentUser) | ||
| 249 | } | ||
| 250 | |||
| 251 | if isAssignedToCurrentUser { | ||
| 252 | Label("Assigned to you", systemImage: "checkmark.circle.fill") | ||
| 253 | .font(.caption.weight(.medium)) | ||
| 254 | .foregroundStyle(.secondary) | ||
| 255 | .padding(.horizontal, 10) | ||
| 256 | .padding(.vertical, 6) | ||
| 257 | .background(Color(.secondarySystemFill), in: Capsule()) | ||
| 258 | } else { | ||
| 259 | Button { | ||
| 260 | Task { | ||
| 261 | await viewModel.assignToCurrentUser(currentUser) | ||
| 262 | } | ||
| 263 | } label: { | ||
| 264 | if viewModel.isPerformingAction { | ||
| 265 | ProgressView() | ||
| 266 | .controlSize(.small) | ||
| 267 | .frame(minWidth: 88) | ||
| 268 | } else { | ||
| 269 | Text("Assign to Me") | ||
| 270 | .font(.caption.weight(.semibold)) | ||
| 271 | .frame(minWidth: 88) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | .buttonStyle(.borderedProminent) | ||
| 275 | .controlSize(.small) | ||
| 276 | .disabled(viewModel.isPerformingAction) | ||
| 277 | } | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 238 | // MARK: - Comment Input | 281 | // MARK: - Comment Input |
| 239 | 282 | ||
| 240 | @ViewBuilder | 283 | @ViewBuilder |
Hutch/Views/Tickets/TicketDetailViewModel.swift +80
| @@ -433,6 +433,64 @@ final class TicketDetailViewModel { | |||
| 433 | isPerformingAction = false | 433 | isPerformingAction = false |
| 434 | } | 434 | } |
| 435 | 435 | ||
| 436 | func assignToCurrentUser(_ user: User) async { | ||
| 437 | guard !isPerformingAction, let currentTicket = ticket else { return } | ||
| 438 | |||
| 439 | let currentAssignees = currentTicket.assignees | ||
| 440 | let currentEntity = Entity(canonicalName: user.canonicalName) | ||
| 441 | guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else { | ||
| 442 | return | ||
| 443 | } | ||
| 444 | |||
| 445 | isPerformingAction = true | ||
| 446 | error = nil | ||
| 447 | |||
| 448 | ticket = TicketDetail( | ||
| 449 | id: currentTicket.id, | ||
| 450 | created: currentTicket.created, | ||
| 451 | updated: currentTicket.updated, | ||
| 452 | title: currentTicket.title, | ||
| 453 | description: currentTicket.description, | ||
| 454 | status: currentTicket.status, | ||
| 455 | resolution: currentTicket.resolution, | ||
| 456 | authenticity: currentTicket.authenticity, | ||
| 457 | submitter: currentTicket.submitter, | ||
| 458 | assignees: currentAssignees + [currentEntity], | ||
| 459 | labels: currentTicket.labels | ||
| 460 | ) | ||
| 461 | |||
| 462 | do { | ||
| 463 | _ = try await client.execute( | ||
| 464 | service: .todo, | ||
| 465 | query: Self.assignUserMutation, | ||
| 466 | variables: [ | ||
| 467 | "trackerId": trackerId, | ||
| 468 | "ticketId": ticketId, | ||
| 469 | "userId": user.id | ||
| 470 | ], | ||
| 471 | responseType: AssignUserResponse.self | ||
| 472 | ) | ||
| 473 | await loadTicket() | ||
| 474 | } catch { | ||
| 475 | ticket = TicketDetail( | ||
| 476 | id: currentTicket.id, | ||
| 477 | created: currentTicket.created, | ||
| 478 | updated: currentTicket.updated, | ||
| 479 | title: currentTicket.title, | ||
| 480 | description: currentTicket.description, | ||
| 481 | status: currentTicket.status, | ||
| 482 | resolution: currentTicket.resolution, | ||
| 483 | authenticity: currentTicket.authenticity, | ||
| 484 | submitter: currentTicket.submitter, | ||
| 485 | assignees: currentAssignees, | ||
| 486 | labels: currentTicket.labels | ||
| 487 | ) | ||
| 488 | self.error = error.localizedDescription | ||
| 489 | } | ||
| 490 | |||
| 491 | isPerformingAction = false | ||
| 492 | } | ||
| 493 | |||
| 436 | func unassignUser(username: String) async { | 494 | func unassignUser(username: String) async { |
| 437 | guard !isPerformingAction else { return } | 495 | guard !isPerformingAction else { return } |
| 438 | isPerformingAction = true | 496 | isPerformingAction = true |
| @@ -558,4 +616,26 @@ final class TicketDetailViewModel { | |||
| 558 | isPerformingAction = false | 616 | isPerformingAction = false |
| 559 | } | 617 | } |
| 560 | 618 | ||
| 619 | static func matchesAssignee(_ entity: Entity, user: User) -> Bool { | ||
| 620 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) | ||
| 621 | let userCanonical = normalizedCanonicalName(user.canonicalName) | ||
| 622 | if assigneeCanonical == userCanonical { | ||
| 623 | return true | ||
| 624 | } | ||
| 625 | return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) | ||
| 626 | } | ||
| 627 | |||
| 628 | private static func normalizedCanonicalName(_ value: String) -> String { | ||
| 629 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 630 | if trimmed.hasPrefix("~") { | ||
| 631 | return trimmed | ||
| 632 | } | ||
| 633 | return "~\(trimmed)" | ||
| 634 | } | ||
| 635 | |||
| 636 | private static func normalizedUsername(_ value: String) -> String { | ||
| 637 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 638 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | ||
| 639 | } | ||
| 640 | |||
| 561 | } | 641 | } |