From 441b69afae30ee6b662be38004fd7b5de1e47302 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 19 Mar 2026 01:17:49 -0500 Subject: feat: add support for reading inbox messages (emails) and replying in-app --- Hutch/Views/Inbox/ThreadViewModel.swift | 702 ++++++++++++++++++++++++++++++++ 1 file changed, 702 insertions(+) create mode 100644 Hutch/Views/Inbox/ThreadViewModel.swift (limited to 'Hutch/Views/Inbox/ThreadViewModel.swift') diff --git a/Hutch/Views/Inbox/ThreadViewModel.swift b/Hutch/Views/Inbox/ThreadViewModel.swift new file mode 100644 index 0000000..10a5b59 --- /dev/null +++ b/Hutch/Views/Inbox/ThreadViewModel.swift @@ -0,0 +1,702 @@ +import Foundation +import os + +private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox") + +private struct InboxThreadDetailResponse: Decodable, Sendable { + let list: InboxThreadDetailList? +} + +private struct InboxThreadDetailList: Decodable, Sendable { + let threads: InboxThreadPayloadPage? +} + +private struct InboxThreadLookupResponse: Decodable, Sendable { + let list: InboxThreadLookupList? +} + +private struct InboxThreadLookupList: Decodable, Sendable { + let message: InboxThreadLookupMessage? +} + +private struct InboxThreadLookupMessage: Decodable, Sendable { + let thread: InboxThreadPayloadDetail? +} + +private struct InboxThreadPayloadDetail: Decodable, Sendable { + let subject: String? + let updated: Date? + let replies: Int? + let sender: Entity? + let list: InboxMailingListReference? + let root: InboxThreadMessagePayload? + let descendants: InboxThreadMessagesPage? +} + +private struct InboxThreadPayloadPage: Decodable, Sendable { + let results: [InboxThreadPayloadDetail] + let cursor: String? +} + +private struct InboxThreadMessagesPage: Decodable, Sendable { + let results: [InboxThreadMessagePayload]? + let cursor: String? +} + +private struct InboxThreadMessagePayload: Decodable, Sendable { + let id: Int? + let sender: Entity? + let received: Date? + let date: Date? + let subject: String? + let messageID: String? + let body: String? + let rawMessage: URL? + let patch: InboxPatchPreview? +} + +@Observable +@MainActor +final class ThreadViewModel { + private(set) var thread: InboxThreadDetail? + private(set) var isLoading = false + var error: String? + var composeDraft: MailComposeDraft? + + private let summary: InboxThreadSummary + private let client: SRHTClient + + private static let threadDetailQuery = """ + query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) { + list(rid: $rid) { + threads(cursor: $cursor) { + results { + subject + updated + replies + sender { canonicalName } + list { + id + rid + name + owner { canonicalName } + } + root { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + descendants(cursor: $descCursor) { + results { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + cursor + } + } + cursor + } + } + } + """ + + private static let threadByMessageIDQuery = """ + query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) { + list(rid: $rid) { + message(messageID: $messageID) { + thread { + subject + updated + replies + sender { canonicalName } + list { + id + rid + name + owner { canonicalName } + } + root { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + descendants(cursor: $descCursor) { + results { + id + sender { canonicalName } + received + date + subject + messageID + body + rawMessage + patch { subject } + } + cursor + } + } + } + } + } + """ + + init(summary: InboxThreadSummary, client: SRHTClient) { + self.summary = summary + self.client = client + } + + func loadThread() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") + + do { + let threadPayloads = try await fetchThreadPayloads() + + guard !threadPayloads.isEmpty else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) + } + + let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference( + id: summary.listID, + rid: summary.listRID, + name: summary.listName, + owner: summary.listOwner + ) + var messagesByID: [Int: InboxMessage] = [:] + + 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 + } + } + + let messages = messagesByID.values.sorted { $0.date < $1.date } + guard !messages.isEmpty else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)]) + } + + let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0] + thread = InboxThreadDetail( + id: summary.id, + rootEmailID: summary.rootEmailID, + rootMessageID: summary.rootMessageID, + subject: latestPayload.subject ?? summary.subject, + author: latestPayload.sender ?? summary.latestSender, + lastActivityAt: latestPayload.updated ?? summary.lastActivityAt, + mailto: nil, + listID: listReference.id, + listRID: listReference.rid, + listName: listReference.name, + listOwner: listReference.owner, + messageCount: max(messages.count, summary.messageCount ?? 0), + messages: messages + ) + } catch { + thread = nil + self.error = error.localizedDescription + inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] { + var payloads: [InboxThreadPayloadDetail] = [] + var seenRoots = Set() + + for rootMessageID in summary.threadRootMessageIDs { + guard !seenRoots.contains(rootMessageID) else { continue } + seenRoots.insert(rootMessageID) + if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) { + payloads.append(payload) + } + } + + if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) { + payloads.append(fallback) + } + + return payloads + } + + private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { + if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) { + return messageMatchedThread + } + return try await scanThreadPages(targetRootMessageID: rootMessageID) + } + + private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { + let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID) + inboxLogger.debug( + "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)" + ) + + var lastLookupError: Error? + + for messageID in candidateMessageIDs { + inboxLogger.debug( + "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)" + ) + + do { + let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( + client: client, + query: Self.threadByMessageIDQuery, + variables: [ + "rid": self.summary.listRID, + "messageID": messageID, + "descCursor": nil as String? + ] + ) + + if let thread = response.list?.message?.thread { + return thread + } + } catch let error as SRHTError { + switch error { + case .graphQLErrors(let errors): + let combinedMessage = errors.map(\.message).joined(separator: " | ") + inboxLogger.error( + "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)" + ) + if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) { + lastLookupError = error + continue + } + throw error + default: + throw error + } + } + } + + if let lastLookupError { + inboxLogger.debug( + "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)" + ) + } + return nil + } + + private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? { + var threadCursor: String? + + while true { + var variables: [String: any Sendable] = ["rid": summary.listRID] + if let threadCursor { + 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 + }() + ) + + guard let threadPage = response.list?.threads else { + throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) + } + + let candidates = threadPage.results.map { payload in + "subject=\(payload.subject ?? "") rootEmailID=\(payload.root?.id.map(String.init) ?? "") rootMessageID=\(payload.root?.messageID ?? "")" + }.joined(separator: " | ") + inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)") + + if let matchedThread = threadPage.results.first(where: { + $0.root?.messageID == targetRootMessageID || + $0.root?.id == summary.rootEmailID || + $0.root?.subject == summary.subject + }) { + return matchedThread + } + + guard let nextCursor = threadPage.cursor else { + return nil + } + threadCursor = nextCursor + } + } + + private func fetchAllDescendantMessages( + initialPayload: InboxThreadPayloadDetail, + candidateMessageIDs: [String] + ) async throws -> [InboxMessage] { + var messagesByID: [Int: InboxMessage] = [:] + + for payload in initialPayload.descendants?.results ?? [] { + if let message = Self.message(from: payload, fallbackID: nil) { + messagesByID[message.id] = message + } + } + + var descendantCursor = initialPayload.descendants?.cursor + while let currentCursor = descendantCursor { + guard let page = try await fetchDescendantPage( + cursor: currentCursor, + candidateMessageIDs: candidateMessageIDs + ) else { + break + } + + for payload in page.results ?? [] { + if let message = Self.message(from: payload, fallbackID: nil) { + messagesByID[message.id] = message + } + } + descendantCursor = page.cursor + } + + return messagesByID.values.sorted { $0.date < $1.date } + } + + private func fetchDescendantPage( + cursor: String, + 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 + ] + ) + + if let descendants = response.list?.message?.thread?.descendants { + return descendants + } + } + + return nil + } + + func prepareReply() { + guard let thread else { + error = "This thread is not ready to reply to yet." + return + } + inboxLogger.debug( + "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" + ) + composeDraft = MailComposeDraft( + recipients: [thread.replyRecipient], + ccRecipients: [], + subject: thread.replySubject, + body: "" + ) + } + + func dismissReply() { + composeDraft = nil + } + + private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? { + guard let payload else { return nil } + guard let id = payload.id ?? fallbackID, + let author = payload.sender, + let date = payload.date ?? payload.received, + let subject = payload.subject, + let body = payload.body else { + return nil + } + + let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) + let displayBody = sanitizedDisplayBody(from: body) + let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil) + + return InboxMessage( + id: id, + author: author, + date: date, + subject: subject, + body: body, + senderDisplayName: normalizedIdentity.displayName, + senderEmailAddress: normalizedIdentity.emailAddress, + isPatch: payload.patch != nil, + contentBlocks: contentBlocks, + rawMessageURL: payload.rawMessage + ) + } + + nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? { + guard let components = URLComponents(string: mailto), + components.scheme?.lowercased() == "mailto" else { + return nil + } + + let recipients = components.path + .split(separator: ",") + .map { String($0) } + .filter { !$0.isEmpty } + let queryItems = components.queryItems ?? [] + let ccRecipients = queryItems + .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })? + .value? + .split(separator: ",") + .map(String.init) ?? [] + let subject = queryItems + .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })? + .value ?? "" + let body = queryItems + .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })? + .value ?? "" + + return MailComposeDraft( + recipients: recipients, + ccRecipients: ccRecipients, + subject: subject, + body: body + ) + } + + private static func messageIDCandidates(from messageID: String) -> [String] { + let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMessageID.isEmpty else { return [] } + + if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") { + return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())] + } + + return [trimmedMessageID, "<\(trimmedMessageID)>"] + } + + private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) { + guard let fromLine = leadingHeaderValue(named: "From", in: body) else { + return fallbackSenderIdentity(from: fallbackAuthor) + } + + let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines) + if let start = trimmedFromLine.lastIndex(of: "<"), + let end = trimmedFromLine.lastIndex(of: ">"), + start < end { + let email = String(trimmedFromLine[trimmedFromLine.index(after: start).. (displayName: String, emailAddress: String?) { + let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) + if canonicalName.contains("@") { + return (canonicalName, canonicalName) + } + if canonicalName.hasPrefix("~") { + return (String(canonicalName.dropFirst()), nil) + } + return (canonicalName, nil) + } + + private static func sanitizedDisplayBody(from body: String) -> String { + let lines = body.components(separatedBy: .newlines) + let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] + var headerCount = 0 + var blankLineIndex: Int? + + for (index, line) in lines.prefix(12).enumerated() { + if line.isEmpty { + blankLineIndex = index + break + } + if headerPrefixes.contains(where: { line.hasPrefix($0) }) { + headerCount += 1 + } else if headerCount > 0 { + break + } + } + + guard headerCount >= 2, let blankLineIndex else { + return body + } + + return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") + } + + nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { + segmentMessageBody(body, isPatch: isPatch) + } + + private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { + guard isPatch else { + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] + } + + let normalizedBody = normalizeLineEndings(in: body) + let lines = normalizedBody.components(separatedBy: "\n") + guard let diffStartIndex = actualDiffStartIndex(in: lines) else { + let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] + } + + var blocks: [InboxMessageContentBlock] = [] + let leadingPlainText = lines[.. + let trailingPlainText: String + if let signatureIndex { + diffLines = remainingLines[.. Int? { + if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) { + return explicitDiffIndex + } + + for index in lines.indices { + let line = lines[index] + guard line.hasPrefix("--- ") else { continue } + let nextIndex = lines.index(after: index) + guard nextIndex < lines.endIndex else { continue } + let nextLine = lines[nextIndex] + guard nextLine.hasPrefix("+++ ") else { continue } + + let oldPath = String(line.dropFirst(4)) + let newPath = String(nextLine.dropFirst(4)) + let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") && + (newPath.hasPrefix("b/") || newPath == "/dev/null") + + if looksLikeUnifiedDiff { + return index + } + } + + return nil + } + + private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool { + line == "-- " || line == "--" + } + + private nonisolated static func normalizeLineEndings(in text: String) -> String { + text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { + let prefix = "\(headerName):" + let lines = body.components(separatedBy: .newlines) + for line in lines.prefix(12) { + if line.isEmpty { + break + } + if line.hasPrefix(prefix) { + return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + private static func executeGraphQLRequest( + client: SRHTClient, + query: String, + variables: [String: any Sendable] + ) async throws -> T { + guard let token = KeychainHelper.loadToken(), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: SRHTService.lists.url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode( + GraphQLRequestBody( + query: query, + variables: variables.mapValues { AnyCodable($0) } + ) + ) + + let (data, _) = try await URLSession.shared.data(for: request) + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "" + inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)") + #endif + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + let envelope = try decoder.decode(GraphQLResponse.self, from: data) + if let errors = envelope.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + guard let payload = envelope.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response")) + ) + } + return payload + } +} -- cgit v1.2.3 From 1e3c748119c6e9eec27f02146f17ea0302ff648a Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 19 Mar 2026 01:53:46 -0500 Subject: feat: add inbox threads and move builds under more --- Hutch/App/RootView.swift | 26 ++--- Hutch/Models/Inbox.swift | 6 + Hutch/Views/Inbox/InboxView.swift | 23 +++- Hutch/Views/Inbox/InboxViewModel.swift | 76 ++++++++---- Hutch/Views/Inbox/ThreadDetailView.swift | 10 +- Hutch/Views/Inbox/ThreadViewModel.swift | 116 ++++++++++++++----- Hutch/Views/Repositories/DiffView.swift | 147 +++++++++++++++++++++++- Hutch/Views/Tickets/TicketDetailView.swift | 51 +++++++- Hutch/Views/Tickets/TicketDetailViewModel.swift | 80 +++++++++++++ 9 files changed, 458 insertions(+), 77 deletions(-) (limited to 'Hutch/Views/Inbox/ThreadViewModel.swift') 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/). - // 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/). + // 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() 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.. 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 + } + } -- cgit v1.2.3