Commit 4c312827dd

4c312827ddbea8ca40c78f0d3aa82a696377a5f4

parent: 787436c54e

Unsigned

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

feat: add inbox threads and move builds under more

Layout: unified · split

Hutch/App/RootView.swift +13 −13
@@ -66,19 +66,6 @@ struct RootView: View {
6666 Label("Repositories", systemImage: "book.closed")
6767 }
6868
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
8269 NavigationStack(path: $ticketsPath) {
8370 TrackerListView()
8471 // Deep link destination for jumping straight to a ticket.
@@ -96,6 +83,19 @@ struct RootView: View {
9683 .tabItem {
9784 Label("Settings", systemImage: "gear")
9885 }
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 }
9999 }
100100 .overlay {
101101 if isResolvingDeepLink {
Hutch/Models/Inbox.swift +6
@@ -178,6 +178,12 @@ enum InboxReadStateStore {
178178 defaults.set(dictionary, forKey: key)
179179 }
180180
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
181187 static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool {
182188 guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else {
183189 return true
Hutch/Views/Inbox/InboxView.swift +22 −1
@@ -31,6 +31,12 @@ struct InboxView: View {
3131 NavigationLink(value: thread) {
3232 InboxThreadRow(thread: thread)
3333 }
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 }
3440 }
3541 }
3642 .listStyle(.plain)
@@ -39,7 +45,7 @@ struct InboxView: View {
3945 SRHTLoadingStateView(message: "Loading inbox…")
4046 } else if let error = viewModel.error, viewModel.threads.isEmpty {
4147 SRHTErrorStateView(
42 title: "Couldn't Load Threads",
48 title: "Failed to load inbox",
4349 message: error,
4450 retryAction: { await viewModel.loadThreads() }
4551 )
@@ -64,6 +70,21 @@ struct InboxView: View {
6470 }
6571 }
6672 }
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 }
6788}
6889
6990private struct InboxThreadRow: View {
Hutch/Views/Inbox/InboxViewModel.swift +53 −23
@@ -134,33 +134,34 @@ final class InboxViewModel {
134134 return lhs.lastActivityAt > rhs.lastActivityAt
135135 }
136136 } catch {
137 threads = []
138 self.error = error.localizedDescription
137 inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)")
138 self.error = "Failed to load inbox"
139139 }
140140 }
141141
142142 func markThreadRead(_ thread: InboxThreadSummary) {
143143 let viewedAt = max(Date(), thread.lastActivityAt)
144144 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
145 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
146 let current = threads[index]
147 threads[index] = InboxThreadSummary(
148 rootEmailID: current.rootEmailID,
149 rootMessageID: current.rootMessageID,
150 threadRootEmailIDs: current.threadRootEmailIDs,
151 threadRootMessageIDs: current.threadRootMessageIDs,
152 listID: current.listID,
153 listRID: current.listRID,
154 listName: current.listName,
155 listOwner: current.listOwner,
156 subject: current.subject,
157 latestSender: current.latestSender,
158 lastActivityAt: current.lastActivityAt,
159 messageCount: current.messageCount,
160 repo: current.repo,
161 containsPatch: current.containsPatch,
162 isUnread: false
145 inboxListLogger.debug(
146 "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
163147 )
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 }
164165 }
165166
166167 private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
@@ -211,7 +212,7 @@ final class InboxViewModel {
211212 do {
212213 return (try await self.fetchThreads(for: mailingList), nil)
213214 } catch {
214 return ([], error.localizedDescription)
215 return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
215216 }
216217 }
217218 }
@@ -229,6 +230,9 @@ final class InboxViewModel {
229230
230231 summaries.append(contentsOf: batchResult.0)
231232 failureMessages.append(contentsOf: batchResult.1)
233 for failure in batchResult.1 {
234 inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)")
235 }
232236 startIndex = endIndex
233237 }
234238
@@ -248,11 +252,15 @@ final class InboxViewModel {
248252 )
249253
250254 return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
251 let threadID = "\(mailingList.rid)#\(thread.root.messageID)"
252255 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)
253258 inboxListLogger.debug(
254259 "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)"
255260 )
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 )
256264 return InboxThreadSummary(
257265 rootEmailID: thread.root.id,
258266 rootMessageID: thread.root.messageID,
@@ -268,7 +276,7 @@ final class InboxViewModel {
268276 messageCount: thread.replies + 1,
269277 repo: Self.deriveRepositoryName(from: mailingList.name),
270278 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
271 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
279 isUnread: isUnread
272280 )
273281 }
274282 }
@@ -318,6 +326,28 @@ final class InboxViewModel {
318326 }
319327 }
320328
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
321351 private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
322352 var seen = Set<String>()
323353 return mailingLists.filter { mailingList in
Hutch/Views/Inbox/ThreadDetailView.swift +9 −1
@@ -93,6 +93,14 @@ struct ThreadDetailView: View {
9393 .padding(.vertical, 4)
9494 }
9595
96 if let partialWarning = viewModel.partialWarning {
97 Section {
98 Text(partialWarning)
99 .font(.caption)
100 .foregroundStyle(.secondary)
101 }
102 }
103
96104 ForEach(thread.messages) { message in
97105 InboxMessageRow(message: message)
98106 }
@@ -111,7 +119,7 @@ struct ThreadDetailView: View {
111119 SRHTLoadingStateView(message: "Loading thread…")
112120 } else if let error = viewModel.error, viewModel.thread == nil {
113121 SRHTErrorStateView(
114 title: "Couldn't Load Thread",
122 title: "Failed to load thread",
115123 message: error,
116124 retryAction: { await viewModel.loadThread() }
117125 )
Hutch/Views/Inbox/ThreadViewModel.swift +87 −29
@@ -61,6 +61,7 @@ final class ThreadViewModel {
6161 private(set) var thread: InboxThreadDetail?
6262 private(set) var isLoading = false
6363 var error: String?
64 var partialWarning: String?
6465 var composeDraft: MailComposeDraft?
6566
6667 private let summary: InboxThreadSummary
@@ -168,6 +169,7 @@ final class ThreadViewModel {
168169 guard !isLoading else { return }
169170 isLoading = true
170171 error = nil
172 partialWarning = nil
171173 defer { isLoading = false }
172174
173175 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
@@ -187,18 +189,27 @@ final class ThreadViewModel {
187189 )
188190 var messagesByID: [Int: InboxMessage] = [:]
189191
192 var hadPartialReplyFailure = false
193
190194 for payload in threadPayloads {
191195 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
192196 continue
193197 }
194198 messagesByID[rootMessage.id] = rootMessage
195199
196 let descendantMessages = try await fetchAllDescendantMessages(
197 initialPayload: payload,
198 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
199 )
200 for message in descendantMessages {
201 messagesByID[message.id] = message
200 do {
201 let descendantMessages = try await fetchAllDescendantMessages(
202 initialPayload: payload,
203 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
204 )
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 )
202213 }
203214 }
204215
@@ -223,9 +234,15 @@ final class ThreadViewModel {
223234 messageCount: max(messages.count, summary.messageCount ?? 0),
224235 messages: messages
225236 )
237 if hadPartialReplyFailure {
238 partialWarning = "Some replies could not be loaded."
239 }
226240 } catch {
227 thread = nil
228 self.error = error.localizedDescription
241 if thread == nil {
242 self.error = "Failed to load thread"
243 } else {
244 self.error = error.localizedDescription
245 }
229246 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
230247 }
231248 }
@@ -318,18 +335,27 @@ final class ThreadViewModel {
318335 variables["cursor"] = threadCursor
319336 }
320337
321 let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest(
322 client: client,
323 query: Self.threadDetailQuery,
324 variables: {
325 var variables = variables
326 variables["descCursor"] = nil as String?
327 return variables
328 }()
329 )
338 let response: InboxThreadDetailResponse
339 do {
340 response = try await Self.executeGraphQLRequest(
341 client: client,
342 query: Self.threadDetailQuery,
343 variables: {
344 var variables = variables
345 variables["descCursor"] = nil as String?
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 }
330356
331357 guard let threadPage = response.list?.threads else {
332 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
358 return nil
333359 }
334360
335361 let candidates = threadPage.results.map { payload in
@@ -389,15 +415,26 @@ final class ThreadViewModel {
389415 candidateMessageIDs: [String]
390416 ) async throws -> InboxThreadMessagesPage? {
391417 for messageID in candidateMessageIDs {
392 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
393 client: client,
394 query: Self.threadByMessageIDQuery,
395 variables: [
396 "rid": summary.listRID,
397 "messageID": messageID,
398 "descCursor": cursor
399 ]
400 )
418 let response: InboxThreadLookupResponse
419 do {
420 response = try await Self.executeGraphQLRequest(
421 client: client,
422 query: Self.threadByMessageIDQuery,
423 variables: [
424 "rid": summary.listRID,
425 "messageID": messageID,
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 }
401438
402439 if let descendants = response.list?.message?.thread?.descendants {
403440 return descendants
@@ -533,7 +570,8 @@ final class ThreadViewModel {
533570 }
534571
535572 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")
537575 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
538576 var headerCount = 0
539577 var blankLineIndex: Int?
@@ -551,7 +589,7 @@ final class ThreadViewModel {
551589 }
552590
553591 guard headerCount >= 2, let blankLineIndex else {
554 return body
592 return stripLeadingFromLineIfPresent(in: normalizedBody)
555593 }
556594
557595 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
@@ -644,6 +682,19 @@ final class ThreadViewModel {
644682 .replacingOccurrences(of: "\r", with: "\n")
645683 }
646684
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
647698 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
648699 let prefix = "\(headerName):"
649700 let lines = body.components(separatedBy: .newlines)
@@ -699,4 +750,11 @@ final class ThreadViewModel {
699750 }
700751 return payload
701752 }
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 }
702760}
Hutch/Views/Repositories/DiffView.swift +141 −6
@@ -9,8 +9,73 @@ struct DiffView: View {
99 let diff: String
1010
1111 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 }
1322
23 private var normalizedDiff: String {
24 diff
25 .replacingOccurrences(of: "\r\n", with: "\n")
26 .replacingOccurrences(of: "\r", with: "\n")
27 }
28}
29
30private 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
75private struct DiffBlockView: View {
76 let lines: [String]
77
78 var body: some View {
1479 VStack(alignment: .leading, spacing: 0) {
1580 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
1681 DiffLineView(line: line)
@@ -19,13 +84,83 @@ struct DiffView: View {
1984 .font(.system(.caption, design: .monospaced))
2085 .frame(maxWidth: .infinity, alignment: .leading)
2186 .background(Color(.secondarySystemBackground))
22 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
2387 }
88}
2489
25 private var normalizedDiff: String {
26 diff
27 .replacingOccurrences(of: "\r\n", with: "\n")
28 .replacingOccurrences(of: "\r", with: "\n")
90private struct DiffFileSection: Identifiable {
91 let id: String
92 let filename: String
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
29164 }
30165}
31166
Hutch/Views/Tickets/TicketDetailView.swift +47 −4
@@ -132,7 +132,7 @@ struct TicketDetailView: View {
132132 ScrollView {
133133 VStack(alignment: .leading, spacing: 0) {
134134 // Header
135 ticketHeader(ticket)
135 ticketHeader(ticket, viewModel: viewModel)
136136
137137 Divider()
138138 .padding(.vertical, 12)
@@ -186,10 +186,16 @@ struct TicketDetailView: View {
186186 // MARK: - Header
187187
188188 @ViewBuilder
189 private func ticketHeader(_ ticket: TicketDetail) -> some View {
189 private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
190190 VStack(alignment: .leading, spacing: 8) {
191 Text(ticket.title)
192 .font(.title3.weight(.semibold))
191 HStack(alignment: .top, spacing: 12) {
192 Text(ticket.title)
193 .font(.title3.weight(.semibold))
194
195 Spacer(minLength: 12)
196
197 assignToMeButton(ticket: ticket, viewModel: viewModel)
198 }
193199
194200 HStack(spacing: 8) {
195201 TicketStatusIcon(status: ticket.status)
@@ -235,6 +241,43 @@ struct TicketDetailView: View {
235241 .padding()
236242 }
237243
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
238281 // MARK: - Comment Input
239282
240283 @ViewBuilder
Hutch/Views/Tickets/TicketDetailViewModel.swift +80
@@ -433,6 +433,64 @@ final class TicketDetailViewModel {
433433 isPerformingAction = false
434434 }
435435
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
436494 func unassignUser(username: String) async {
437495 guard !isPerformingAction else { return }
438496 isPerformingAction = true
@@ -558,4 +616,26 @@ final class TicketDetailViewModel {
558616 isPerformingAction = false
559617 }
560618
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
561641}