diff options
| author | Christian Cleberg <[email protected]> | 2026-03-19 01:53:46 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-19 01:53:46 -0500 |
| commit | 1e3c748119c6e9eec27f02146f17ea0302ff648a (patch) | |
| tree | 6a5a7c6d22a9022b1b3daa3710768d12981ca0ed | |
| parent | 441b69afae30ee6b662be38004fd7b5de1e47302 (diff) | |
| download | hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.gz hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.tar.bz2 hutch-1e3c748119c6e9eec27f02146f17ea0302ff648a.zip | |
feat: add inbox threads and move builds under more
| -rw-r--r-- | Hutch/App/RootView.swift | 26 | ||||
| -rw-r--r-- | Hutch/Models/Inbox.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxView.swift | 23 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/InboxViewModel.swift | 76 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadDetailView.swift | 10 | ||||
| -rw-r--r-- | Hutch/Views/Inbox/ThreadViewModel.swift | 116 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/DiffView.swift | 147 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 51 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 80 |
9 files changed, 458 insertions, 77 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 36cbe5c..042494f 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -66,19 +66,6 @@ struct RootView: View { Label("Repositories", systemImage: "book.closed") } - NavigationStack(path: $buildsPath) { - BuildListView() - // Int destination used by deep links (hutch://builds/<id>). - // JobSummary destination is registered inside BuildListView. - .navigationDestination(for: Int.self) { jobId in - BuildDetailView(jobId: jobId) - } - } - .tag(AppState.Tab.builds) - .tabItem { - Label("Builds", systemImage: "hammer") - } - NavigationStack(path: $ticketsPath) { TrackerListView() // Deep link destination for jumping straight to a ticket. @@ -96,6 +83,19 @@ struct RootView: View { .tabItem { Label("Settings", systemImage: "gear") } + + NavigationStack(path: $buildsPath) { + BuildListView() + // Int destination used by deep links (hutch://builds/<id>). + // JobSummary destination is registered inside BuildListView. + .navigationDestination(for: Int.self) { jobId in + BuildDetailView(jobId: jobId) + } + } + .tag(AppState.Tab.builds) + .tabItem { + Label("Builds", systemImage: "hammer") + } } .overlay { if isResolvingDeepLink { diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index 720c71f..54e2685 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -178,6 +178,12 @@ enum InboxReadStateStore { defaults.set(dictionary, forKey: key) } + static func markUnread(for threadID: String, defaults: UserDefaults = .standard) { + var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] + dictionary.removeValue(forKey: threadID) + defaults.set(dictionary, forKey: key) + } + static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { return true diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift index e5f3619..602f8d9 100644 --- a/Hutch/Views/Inbox/InboxView.swift +++ b/Hutch/Views/Inbox/InboxView.swift @@ -31,6 +31,12 @@ struct InboxView: View { NavigationLink(value: thread) { InboxThreadRow(thread: thread) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + readStateAction(for: thread, in: viewModel) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + readStateAction(for: thread, in: viewModel) + } } } .listStyle(.plain) @@ -39,7 +45,7 @@ struct InboxView: View { SRHTLoadingStateView(message: "Loading inbox…") } else if let error = viewModel.error, viewModel.threads.isEmpty { SRHTErrorStateView( - title: "Couldn't Load Threads", + title: "Failed to load inbox", message: error, retryAction: { await viewModel.loadThreads() } ) @@ -64,6 +70,21 @@ struct InboxView: View { } } } + + @ViewBuilder + private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.toggleThreadReadState(thread) + } + } label: { + Label( + thread.isUnread ? "Mark as Read" : "Mark as Unread", + systemImage: thread.isUnread ? "envelope.open" : "envelope.badge" + ) + } + .tint(thread.isUnread ? .blue : .gray) + } } private struct InboxThreadRow: View { diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift index 808a5ef..9211b40 100644 --- a/Hutch/Views/Inbox/InboxViewModel.swift +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -134,33 +134,34 @@ final class InboxViewModel { return lhs.lastActivityAt > rhs.lastActivityAt } } catch { - threads = [] - self.error = error.localizedDescription + inboxListLogger.error("Inbox request failed: type=inbox error=\(error.localizedDescription, privacy: .public)") + self.error = "Failed to load inbox" } } func markThreadRead(_ thread: InboxThreadSummary) { let viewedAt = max(Date(), thread.lastActivityAt) InboxReadStateStore.markViewed(viewedAt, for: thread.id) - guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } - let current = threads[index] - threads[index] = InboxThreadSummary( - rootEmailID: current.rootEmailID, - rootMessageID: current.rootMessageID, - threadRootEmailIDs: current.threadRootEmailIDs, - threadRootMessageIDs: current.threadRootMessageIDs, - listID: current.listID, - listRID: current.listRID, - listName: current.listName, - listOwner: current.listOwner, - subject: current.subject, - latestSender: current.latestSender, - lastActivityAt: current.lastActivityAt, - messageCount: current.messageCount, - repo: current.repo, - containsPatch: current.containsPatch, - isUnread: false + inboxListLogger.debug( + "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)" ) + updateThread(thread, isUnread: false) + } + + func markThreadUnread(_ thread: InboxThreadSummary) { + InboxReadStateStore.markUnread(for: thread.id) + inboxListLogger.debug( + "Inbox mark unread: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=nil" + ) + updateThread(thread, isUnread: true) + } + + func toggleThreadReadState(_ thread: InboxThreadSummary) { + if thread.isUnread { + markThreadRead(thread) + } else { + markThreadUnread(thread) + } } private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { @@ -211,7 +212,7 @@ final class InboxViewModel { do { return (try await self.fetchThreads(for: mailingList), nil) } catch { - return ([], error.localizedDescription) + return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)") } } } @@ -229,6 +230,9 @@ final class InboxViewModel { summaries.append(contentsOf: batchResult.0) failureMessages.append(contentsOf: batchResult.1) + for failure in batchResult.1 { + inboxListLogger.error("Inbox request failed: type=listThreads \(failure, privacy: .public)") + } startIndex = endIndex } @@ -248,11 +252,15 @@ final class InboxViewModel { ) return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in - let threadID = "\(mailingList.rid)#\(thread.root.messageID)" 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())" + let lastViewedAt = InboxReadStateStore.lastViewedAt(for: groupingKey) + let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated) inboxListLogger.debug( "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)" ) + inboxListLogger.debug( + "Inbox unread state: key=\(groupingKey, privacy: .public) latestActivityAt=\(thread.updated.ISO8601Format(), privacy: .public) lastViewedAt=\(lastViewedAt?.ISO8601Format() ?? "nil", privacy: .public) isUnread=\(isUnread, privacy: .public)" + ) return InboxThreadSummary( rootEmailID: thread.root.id, rootMessageID: thread.root.messageID, @@ -268,7 +276,7 @@ final class InboxViewModel { messageCount: thread.replies + 1, repo: Self.deriveRepositoryName(from: mailingList.name), containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), - isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) + isUnread: isUnread ) } } @@ -318,6 +326,28 @@ final class InboxViewModel { } } + private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) { + guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } + let current = threads[index] + threads[index] = InboxThreadSummary( + rootEmailID: current.rootEmailID, + rootMessageID: current.rootMessageID, + threadRootEmailIDs: current.threadRootEmailIDs, + threadRootMessageIDs: current.threadRootMessageIDs, + listID: current.listID, + listRID: current.listRID, + listName: current.listName, + listOwner: current.listOwner, + subject: current.subject, + latestSender: current.latestSender, + lastActivityAt: current.lastActivityAt, + messageCount: current.messageCount, + repo: current.repo, + containsPatch: current.containsPatch, + isUnread: isUnread + ) + } + private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { var seen = Set<String>() return mailingLists.filter { mailingList in diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift index 3c42e7f..7677698 100644 --- a/Hutch/Views/Inbox/ThreadDetailView.swift +++ b/Hutch/Views/Inbox/ThreadDetailView.swift @@ -93,6 +93,14 @@ struct ThreadDetailView: View { .padding(.vertical, 4) } + if let partialWarning = viewModel.partialWarning { + Section { + Text(partialWarning) + .font(.caption) + .foregroundStyle(.secondary) + } + } + ForEach(thread.messages) { message in InboxMessageRow(message: message) } @@ -111,7 +119,7 @@ struct ThreadDetailView: View { SRHTLoadingStateView(message: "Loading thread…") } else if let error = viewModel.error, viewModel.thread == nil { SRHTErrorStateView( - title: "Couldn't Load Thread", + title: "Failed to load thread", message: error, retryAction: { await viewModel.loadThread() } ) diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift index 10a5b59..c042fba 100644 --- a/Hutch/Views/Inbox/ThreadViewModel.swift +++ b/Hutch/Views/Inbox/ThreadViewModel.swift @@ -61,6 +61,7 @@ final class ThreadViewModel { private(set) var thread: InboxThreadDetail? private(set) var isLoading = false var error: String? + var partialWarning: String? var composeDraft: MailComposeDraft? private let summary: InboxThreadSummary @@ -168,6 +169,7 @@ final class ThreadViewModel { guard !isLoading else { return } isLoading = true error = nil + partialWarning = nil defer { isLoading = false } inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") @@ -187,18 +189,27 @@ final class ThreadViewModel { ) var messagesByID: [Int: InboxMessage] = [:] + var hadPartialReplyFailure = false + for payload in threadPayloads { guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { continue } messagesByID[rootMessage.id] = rootMessage - let descendantMessages = try await fetchAllDescendantMessages( - initialPayload: payload, - candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) - ) - for message in descendantMessages { - messagesByID[message.id] = message + do { + let descendantMessages = try await fetchAllDescendantMessages( + initialPayload: payload, + candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) + ) + for message in descendantMessages { + messagesByID[message.id] = message + } + } catch { + hadPartialReplyFailure = true + inboxLogger.error( + "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) } } @@ -223,9 +234,15 @@ final class ThreadViewModel { messageCount: max(messages.count, summary.messageCount ?? 0), messages: messages ) + if hadPartialReplyFailure { + partialWarning = "Some replies could not be loaded." + } } catch { - thread = nil - self.error = error.localizedDescription + if thread == nil { + self.error = "Failed to load thread" + } else { + self.error = error.localizedDescription + } inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") } } @@ -318,18 +335,27 @@ final class ThreadViewModel { variables["cursor"] = threadCursor } - let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest( - client: client, - query: Self.threadDetailQuery, - variables: { - var variables = variables - variables["descCursor"] = nil as String? - return variables - }() - ) + let response: InboxThreadDetailResponse + do { + response = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadDetailQuery, + variables: { + var variables = variables + variables["descCursor"] = nil as String? + return variables + }() + ) + } catch { + if Self.isRecoverableNoRows(error) { + inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + throw error + } guard let threadPage = response.list?.threads else { - throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) + return nil } let candidates = threadPage.results.map { payload in @@ -389,15 +415,26 @@ final class ThreadViewModel { candidateMessageIDs: [String] ) async throws -> InboxThreadMessagesPage? { for messageID in candidateMessageIDs { - let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( - client: client, - query: Self.threadByMessageIDQuery, - variables: [ - "rid": summary.listRID, - "messageID": messageID, - "descCursor": cursor - ] - ) + let response: InboxThreadLookupResponse + do { + response = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadByMessageIDQuery, + variables: [ + "rid": summary.listRID, + "messageID": messageID, + "descCursor": cursor + ] + ) + } catch { + if Self.isRecoverableNoRows(error) { + inboxLogger.error( + "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + continue + } + throw error + } if let descendants = response.list?.message?.thread?.descendants { return descendants @@ -533,7 +570,8 @@ final class ThreadViewModel { } private static func sanitizedDisplayBody(from body: String) -> String { - let lines = body.components(separatedBy: .newlines) + let normalizedBody = normalizeLineEndings(in: body) + let lines = normalizedBody.components(separatedBy: "\n") let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] var headerCount = 0 var blankLineIndex: Int? @@ -551,7 +589,7 @@ final class ThreadViewModel { } guard headerCount >= 2, let blankLineIndex else { - return body + return stripLeadingFromLineIfPresent(in: normalizedBody) } return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") @@ -644,6 +682,19 @@ final class ThreadViewModel { .replacingOccurrences(of: "\r", with: "\n") } + private static func stripLeadingFromLineIfPresent(in body: String) -> String { + let lines = body.components(separatedBy: "\n") + guard let firstLine = lines.first, firstLine.hasPrefix("From:") else { + return body + } + + var remainingLines = Array(lines.dropFirst()) + if let nextLine = remainingLines.first, nextLine.isEmpty { + remainingLines.removeFirst() + } + return remainingLines.joined(separator: "\n") + } + private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { let prefix = "\(headerName):" let lines = body.components(separatedBy: .newlines) @@ -699,4 +750,11 @@ final class ThreadViewModel { } return payload } + + private static func isRecoverableNoRows(_ error: Error) -> Bool { + guard case let SRHTError.graphQLErrors(errors) = error else { + return false + } + return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") } + } } diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift index 4380e22..4b8e512 100644 --- a/Hutch/Views/Repositories/DiffView.swift +++ b/Hutch/Views/Repositories/DiffView.swift @@ -9,8 +9,73 @@ struct DiffView: View { let diff: String var body: some View { - let lines = normalizedDiff.components(separatedBy: "\n") + VStack(alignment: .leading, spacing: 12) { + ForEach(fileSections) { section in + DiffFileSectionView(section: section) + } + } + } + + private var fileSections: [DiffFileSection] { + DiffFileSection.parse(from: normalizedDiff) + } + private var normalizedDiff: String { + diff + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } +} + +private struct DiffFileSectionView: View { + let section: DiffFileSection + @State private var isExpanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 10) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 12) + + Text(section.filename) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(section.changeSummary) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color(.tertiarySystemBackground)) + + if isExpanded { + DiffBlockView(lines: section.lines) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06)) + } + } +} + +private struct DiffBlockView: View { + let lines: [String] + + var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in DiffLineView(line: line) @@ -19,13 +84,83 @@ struct DiffView: View { .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } +} - private var normalizedDiff: String { - diff - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") +private struct DiffFileSection: Identifiable { + let id: String + let filename: String + let lines: [String] + let additions: Int + let deletions: Int + + var changeSummary: String { + "+\(additions) -\(deletions)" + } + + static func parse(from diff: String) -> [DiffFileSection] { + let lines = diff.components(separatedBy: "\n") + guard !lines.isEmpty else { return [] } + + let boundaries = lines.enumerated().compactMap { index, line in + line.hasPrefix("diff --git ") ? index : nil + } + + guard !boundaries.isEmpty else { + let section = makeSection(lines: lines, fallbackIndex: 0) + return section.lines.isEmpty ? [] : [section] + } + + var sections: [DiffFileSection] = [] + for (position, startIndex) in boundaries.enumerated() { + let endIndex = position + 1 < boundaries.count ? boundaries[position + 1] : lines.count + let sectionLines = Array(lines[startIndex..<endIndex]) + let section = makeSection(lines: sectionLines, fallbackIndex: position) + if !section.lines.isEmpty { + sections.append(section) + } + } + return sections + } + + private static func makeSection(lines: [String], fallbackIndex: Int) -> DiffFileSection { + let filename = fileName(from: lines) ?? "File \(fallbackIndex + 1)" + let additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count + let deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count + return DiffFileSection( + id: "\(fallbackIndex)-\(filename)", + filename: filename, + lines: lines, + additions: additions, + deletions: deletions + ) + } + + private static func fileName(from lines: [String]) -> String? { + if let diffHeader = lines.first(where: { $0.hasPrefix("diff --git ") }) { + let parts = diffHeader.split(separator: " ") + if let rhs = parts.last, rhs.hasPrefix("b/") { + return String(rhs.dropFirst(2)) + } + } + + if let plusHeader = lines.first(where: { $0.hasPrefix("+++ ") }) { + let path = String(plusHeader.dropFirst(4)) + if path.hasPrefix("b/") { + return String(path.dropFirst(2)) + } + return path + } + + if let minusHeader = lines.first(where: { $0.hasPrefix("--- ") }) { + let path = String(minusHeader.dropFirst(4)) + if path.hasPrefix("a/") { + return String(path.dropFirst(2)) + } + return path + } + + return nil } } diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index f553bd8..5ca8618 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -132,7 +132,7 @@ struct TicketDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { // Header - ticketHeader(ticket) + ticketHeader(ticket, viewModel: viewModel) Divider() .padding(.vertical, 12) @@ -186,10 +186,16 @@ struct TicketDetailView: View { // MARK: - Header @ViewBuilder - private func ticketHeader(_ ticket: TicketDetail) -> some View { + private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { VStack(alignment: .leading, spacing: 8) { - Text(ticket.title) - .font(.title3.weight(.semibold)) + HStack(alignment: .top, spacing: 12) { + Text(ticket.title) + .font(.title3.weight(.semibold)) + + Spacer(minLength: 12) + + assignToMeButton(ticket: ticket, viewModel: viewModel) + } HStack(spacing: 8) { TicketStatusIcon(status: ticket.status) @@ -235,6 +241,43 @@ struct TicketDetailView: View { .padding() } + @ViewBuilder + private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View { + if let currentUser = appState.currentUser { + let isAssignedToCurrentUser = ticket.assignees.contains { + TicketDetailViewModel.matchesAssignee($0, user: currentUser) + } + + if isAssignedToCurrentUser { + Label("Assigned to you", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(.secondarySystemFill), in: Capsule()) + } else { + Button { + Task { + await viewModel.assignToCurrentUser(currentUser) + } + } label: { + if viewModel.isPerformingAction { + ProgressView() + .controlSize(.small) + .frame(minWidth: 88) + } else { + Text("Assign to Me") + .font(.caption.weight(.semibold)) + .frame(minWidth: 88) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(viewModel.isPerformingAction) + } + } + } + // MARK: - Comment Input @ViewBuilder diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 90e33aa..660715b 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -433,6 +433,64 @@ final class TicketDetailViewModel { isPerformingAction = false } + func assignToCurrentUser(_ user: User) async { + guard !isPerformingAction, let currentTicket = ticket else { return } + + let currentAssignees = currentTicket.assignees + let currentEntity = Entity(canonicalName: user.canonicalName) + guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else { + return + } + + isPerformingAction = true + error = nil + + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees + [currentEntity], + labels: currentTicket.labels + ) + + do { + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": user.id + ], + responseType: AssignUserResponse.self + ) + await loadTicket() + } catch { + ticket = TicketDetail( + id: currentTicket.id, + created: currentTicket.created, + updated: currentTicket.updated, + title: currentTicket.title, + description: currentTicket.description, + status: currentTicket.status, + resolution: currentTicket.resolution, + authenticity: currentTicket.authenticity, + submitter: currentTicket.submitter, + assignees: currentAssignees, + labels: currentTicket.labels + ) + self.error = error.localizedDescription + } + + isPerformingAction = false + } + func unassignUser(username: String) async { guard !isPerformingAction else { return } isPerformingAction = true @@ -558,4 +616,26 @@ final class TicketDetailViewModel { isPerformingAction = false } + static func matchesAssignee(_ entity: Entity, user: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let userCanonical = normalizedCanonicalName(user.canonicalName) + if assigneeCanonical == userCanonical { + return true + } + return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) + } + + private static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + } |
