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/App/AppState.swift | 14 + Hutch/App/RootView.swift | 42 +- Hutch/Models/Inbox.swift | 187 ++++++++ Hutch/Views/Home/HomeView.swift | 25 +- Hutch/Views/Inbox/InboxView.swift | 107 +++++ Hutch/Views/Inbox/InboxViewModel.swift | 335 +++++++++++++++ Hutch/Views/Inbox/ThreadDetailView.swift | 324 ++++++++++++++ Hutch/Views/Inbox/ThreadViewModel.swift | 702 +++++++++++++++++++++++++++++++ Hutch/Views/Repositories/DiffView.swift | 22 +- HutchTests/InboxViewModelTests.swift | 168 ++++++++ 10 files changed, 1897 insertions(+), 29 deletions(-) create mode 100644 Hutch/Models/Inbox.swift create mode 100644 Hutch/Views/Inbox/InboxView.swift create mode 100644 Hutch/Views/Inbox/InboxViewModel.swift create mode 100644 Hutch/Views/Inbox/ThreadDetailView.swift create mode 100644 Hutch/Views/Inbox/ThreadViewModel.swift create mode 100644 HutchTests/InboxViewModelTests.swift diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index 624c78d..7492fa6 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -7,6 +7,15 @@ import WebKit @MainActor final class AppState { + enum Tab: Hashable { + case home + case inbox + case repositories + case builds + case tickets + case settings + } + enum AuthPhase { /// App just launched, checking for an existing token. case launching @@ -25,6 +34,8 @@ final class AppState { authPhase == .authenticated && currentUser != nil } + var selectedTab: Tab = .home + // MARK: - Current user (populated after successful validation) private(set) var currentUser: User? @@ -94,6 +105,7 @@ final class AppState { await clearWebData() clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } func resetAppData() async { @@ -108,6 +120,7 @@ final class AppState { clearWebContentRenderCaches() authPhase = .unauthenticated + selectedTab = .home } // MARK: - Deep link resolution @@ -208,6 +221,7 @@ final class AppState { client.responseCache.clear() currentUser = nil pendingDeepLink = nil + selectedTab = .home } private func clearWebData() async { diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 79411ed..36cbe5c 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -4,17 +4,8 @@ import SwiftUI /// full-screen sheet for token entry on first launch. struct RootView: View { @Environment(AppState.self) private var appState - - enum Tab: Hashable { - case home - case repositories - case builds - case tickets - case settings - } - - @State private var selectedTab: Tab = .home @State private var homePath = NavigationPath() + @State private var inboxPath = NavigationPath() @State private var repoPath = NavigationPath() @State private var buildsPath = NavigationPath() @State private var ticketsPath = NavigationPath() @@ -48,19 +39,29 @@ struct RootView: View { // MARK: - Tab View private var tabContent: some View { - TabView(selection: $selectedTab) { + @Bindable var appState = appState + + return TabView(selection: $appState.selectedTab) { NavigationStack(path: $homePath) { HomeView() } - .tag(Tab.home) + .tag(AppState.Tab.home) .tabItem { Label("Home", systemImage: "house") } + NavigationStack(path: $inboxPath) { + InboxView() + } + .tag(AppState.Tab.inbox) + .tabItem { + Label("Inbox", systemImage: "tray") + } + NavigationStack(path: $repoPath) { RepositoryListView() } - .tag(Tab.repositories) + .tag(AppState.Tab.repositories) .tabItem { Label("Repositories", systemImage: "book.closed") } @@ -73,7 +74,7 @@ struct RootView: View { BuildDetailView(jobId: jobId) } } - .tag(Tab.builds) + .tag(AppState.Tab.builds) .tabItem { Label("Builds", systemImage: "hammer") } @@ -85,13 +86,13 @@ struct RootView: View { TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) } } - .tag(Tab.tickets) + .tag(AppState.Tab.tickets) .tabItem { Label("Tickets", systemImage: "ticket") } SettingsView() - .tag(Tab.settings) + .tag(AppState.Tab.settings) .tabItem { Label("Settings", systemImage: "gear") } @@ -117,10 +118,11 @@ struct RootView: View { break case .unauthenticated: homePath = NavigationPath() + inboxPath = NavigationPath() repoPath = NavigationPath() buildsPath = NavigationPath() ticketsPath = NavigationPath() - selectedTab = .home + appState.selectedTab = .home isResolvingDeepLink = false case .authenticated: consumePendingDeepLinkIfPossible(appState.pendingDeepLink) @@ -143,7 +145,7 @@ struct RootView: View { case .build(let jobId): // Reset the builds navigation and push the detail buildsPath = NavigationPath() - selectedTab = .builds + appState.selectedTab = .builds // Defer the push slightly so the tab switch takes effect Task { @MainActor in try? await Task.sleep(for: .milliseconds(100)) @@ -162,7 +164,7 @@ struct RootView: View { do { let summary = try await appState.resolveRepository(owner: owner, name: repo) repoPath = NavigationPath() - selectedTab = .repositories + appState.selectedTab = .repositories try? await Task.sleep(for: .milliseconds(100)) repoPath.append(summary) } catch { @@ -178,7 +180,7 @@ struct RootView: View { do { let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) ticketsPath = NavigationPath() - selectedTab = .tickets + appState.selectedTab = .tickets try? await Task.sleep(for: .milliseconds(100)) ticketsPath.append(trackerSummary) try? await Task.sleep(for: .milliseconds(100)) diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift new file mode 100644 index 0000000..720c71f --- /dev/null +++ b/Hutch/Models/Inbox.swift @@ -0,0 +1,187 @@ +import Foundation + +struct InboxThreadSummary: Identifiable, Hashable, Sendable { + let rootEmailID: Int + let rootMessageID: String + let threadRootEmailIDs: [Int] + let threadRootMessageIDs: [String] + let listID: Int + let listRID: String + let listName: String + let listOwner: Entity + let subject: String + let latestSender: Entity + let lastActivityAt: Date + let messageCount: Int? + let repo: String? + let containsPatch: Bool + let isUnread: Bool + + var id: String { + threadGroupingKey + } + + var listDisplayName: String { + "\(listOwner.canonicalName)/\(listName)" + } + + var displaySubject: String { + Self.normalizedSubject(from: subject) + } + + var metadataLine: String { + var parts = [latestSenderDisplayName] + if let messageCount, messageCount > 1 { + let replyCount = max(messageCount - 1, 1) + parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")") + } + parts.append(lastActivityAt.relativeDescription) + return parts.joined(separator: " • ") + } + + var latestSenderDisplayName: String { + let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) + if canonicalName.contains("@") { + return canonicalName + } + return canonicalName + } + + var debugIdentifierSummary: String { + "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)" + } + + var threadGroupingKey: String { + "\(listRID)#\(displaySubject.lowercased())" + } + + private static func normalizedSubject(from subject: String) -> String { + let collapsedWhitespace = subject + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"# + return collapsedWhitespace.replacingOccurrences( + of: pattern, + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } +} + +struct InboxMessage: Identifiable, Hashable, Sendable { + let id: Int + let author: Entity + let date: Date + let subject: String + let body: String + let senderDisplayName: String + let senderEmailAddress: String? + let isPatch: Bool + let contentBlocks: [InboxMessageContentBlock] + let rawMessageURL: URL? +} + +enum InboxMessageContentBlock: Hashable, Sendable { + case plainText(String) + case diff(String) +} + +struct InboxThreadDetail: Sendable { + let id: String + let rootEmailID: Int + let rootMessageID: String + let subject: String + let author: Entity + let lastActivityAt: Date + let mailto: String? + let listID: Int + let listRID: String + let listName: String + let listOwner: Entity + let messageCount: Int? + let messages: [InboxMessage] + + var listDisplayName: String { + "\(listOwner.canonicalName)/\(listName)" + } +} + +extension InboxThreadDetail { + var replyRecipient: String { + "\(listOwner.canonicalName)/\(listName)@lists.sr.ht" + } + + var replySubject: String { + subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)" + } + + var displaySubject: String { + InboxThreadSummary( + rootEmailID: rootEmailID, + rootMessageID: rootMessageID, + threadRootEmailIDs: [rootEmailID], + threadRootMessageIDs: [rootMessageID], + listID: listID, + listRID: listRID, + listName: listName, + listOwner: listOwner, + subject: subject, + latestSender: author, + lastActivityAt: lastActivityAt, + messageCount: messageCount, + repo: nil, + containsPatch: messages.contains(where: \.isPatch), + isUnread: false + ).displaySubject + } +} + +struct MailComposeDraft: Sendable { + let recipients: [String] + let ccRecipients: [String] + let subject: String + let body: String + + var id: String { + ([subject] + recipients + ccRecipients).joined(separator: "|") + } +} + +extension MailComposeDraft: Identifiable {} + +struct InboxMailingListReference: Decodable, Sendable, Hashable { + let id: Int + let rid: String + let name: String + let owner: Entity +} + +struct InboxPatchPreview: Decodable, Sendable, Hashable { + let subject: String? +} + +enum InboxReadStateStore { + private static let key = "InboxThreadLastViewed" + + static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { + guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], + let timestamp = dictionary[threadID] else { + return nil + } + return Date(timeIntervalSince1970: timestamp) + } + + static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) { + var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] + dictionary[threadID] = date.timeIntervalSince1970 + 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 + } + return lastActivityAt > lastViewedAt + } +} diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index a72853f..1e98039 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -115,8 +115,8 @@ struct HomeView: View { } } } header: { - HomeSectionHeader("Recent Builds") { - BuildListView() + HomeSectionActionHeader("Recent Builds") { + appState.selectedTab = .builds } } } @@ -243,6 +243,27 @@ private struct HomeSectionHeader: View { } } +private struct HomeSectionActionHeader: View { + let title: String + let action: () -> Void + + init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + HStack { + Text(title) + Spacer() + Button("See All", action: action) + .font(.caption.weight(.medium)) + .buttonStyle(.plain) + } + .textCase(nil) + } +} + private struct HomeAssignedTicketsListView: View { let viewModel: HomeViewModel diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift new file mode 100644 index 0000000..e5f3619 --- /dev/null +++ b/Hutch/Views/Inbox/InboxView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct InboxView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: InboxViewModel? + + var body: some View { + Group { + if let viewModel { + listContent(viewModel) + } else { + SRHTLoadingStateView(message: "Loading inbox…") + } + } + .navigationTitle("Inbox") + .task { + if viewModel == nil { + let vm = InboxViewModel(client: appState.client) + viewModel = vm + await vm.loadThreads() + } + } + } + + @ViewBuilder + private func listContent(_ viewModel: InboxViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.threads) { thread in + NavigationLink(value: thread) { + InboxThreadRow(thread: thread) + } + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.threads.isEmpty { + SRHTLoadingStateView(message: "Loading inbox…") + } else if let error = viewModel.error, viewModel.threads.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Threads", + message: error, + retryAction: { await viewModel.loadThreads() } + ) + } else if viewModel.threads.isEmpty, viewModel.error == nil { + ContentUnavailableView( + "No Threads", + systemImage: "tray", + description: Text("Patch threads will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) { + await viewModel.loadThreads() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadThreads() + } + .navigationDestination(for: InboxThreadSummary.self) { thread in + ThreadDetailView(thread: thread) { + viewModel.markThreadRead(thread) + } + } + } +} + +private struct InboxThreadRow: View { + let thread: InboxThreadSummary + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(thread.isUnread ? .blue : .clear) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + Text(thread.displaySubject) + .font(.subheadline.weight(thread.isUnread ? .semibold : .medium)) + .lineLimit(2) + + HStack(spacing: 8) { + if thread.containsPatch { + Image(systemName: "arrow.triangle.branch") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(thread.metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + Text(thread.lastActivityAt.relativeDescription) + .font(.caption) + .foregroundStyle(.tertiary.opacity(0.7)) + .lineLimit(1) + } + .padding(.vertical, 2) + } +} diff --git a/Hutch/Views/Inbox/InboxViewModel.swift b/Hutch/Views/Inbox/InboxViewModel.swift new file mode 100644 index 0000000..808a5ef --- /dev/null +++ b/Hutch/Views/Inbox/InboxViewModel.swift @@ -0,0 +1,335 @@ +import Foundation +import os + +private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList") + +private struct InboxSubscriptionsResponse: Decodable, Sendable { + let subscriptions: InboxSubscriptionPage +} + +private struct InboxSubscriptionPage: Decodable, Sendable { + let results: [InboxActivitySubscription] + let cursor: String? +} + +private struct InboxActivitySubscription: Decodable, Sendable { + let id: Int + let created: Date + let list: InboxMailingListReference? + + enum CodingKeys: String, CodingKey { + case id + case created + case list + } +} + +private struct InboxListThreadsResponse: Decodable, Sendable { + let list: InboxMailingListThreads +} + +private struct InboxMailingListThreads: Decodable, Sendable { + let threads: InboxThreadPage +} + +private struct InboxThreadPage: Decodable, Sendable { + let results: [InboxThreadPayload] + let cursor: String? +} + +private struct InboxThreadPayload: Decodable, Sendable { + let created: Date + let updated: Date + let subject: String + let replies: Int + let sender: Entity + let root: InboxEmailPreview +} + +private struct InboxEmailPreview: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let received: Date + let messageID: String + let body: String + let patch: InboxPatchPreview? +} + +@Observable +@MainActor +final class InboxViewModel { + private(set) var threads: [InboxThreadSummary] = [] + private(set) var isLoading = false + var error: String? + + private let client: SRHTClient + private let listThreadFetchLimit = 10 + private let listFetchConcurrencyLimit = 4 + + private static let subscriptionsQuery = """ + query inboxSubscriptions($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { + id + created + list { + id + rid + name + owner { canonicalName } + } + } + } + cursor + } + } + """ + + private static let listThreadsQuery = """ + query inboxListThreads($rid: ID!, $cursor: Cursor) { + list(rid: $rid) { + threads(cursor: $cursor) { + results { + created + updated + subject + replies + sender { canonicalName } + root { + id + subject + date + received + messageID + body + patch { subject } + } + } + cursor + } + } + } + """ + + init(client: SRHTClient) { + self.client = client + } + + func loadThreads() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + let subscriptions = try await fetchSubscriptions() + let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) + let fetchedThreads = try await fetchThreads(for: mailingLists) + threads = fetchedThreads.sorted { lhs, rhs in + if lhs.lastActivityAt == rhs.lastActivityAt { + return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending + } + return lhs.lastActivityAt > rhs.lastActivityAt + } + } catch { + threads = [] + self.error = error.localizedDescription + } + } + + 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 + ) + } + + private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { + var subscriptions: [InboxActivitySubscription] = [] + var cursor: String? + + while true { + var variables: [String: any Sendable] = [:] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .lists, + query: Self.subscriptionsQuery, + variables: variables.isEmpty ? nil : variables, + responseType: InboxSubscriptionsResponse.self + ) + + subscriptions.append(contentsOf: response.subscriptions.results) + guard let nextCursor = response.subscriptions.cursor else { + break + } + cursor = nextCursor + } + + return subscriptions + } + + private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] { + guard !mailingLists.isEmpty else { return [] } + + var summaries: [InboxThreadSummary] = [] + var startIndex = mailingLists.startIndex + var failureMessages: [String] = [] + + while startIndex < mailingLists.endIndex { + let endIndex = mailingLists.index( + startIndex, + offsetBy: listFetchConcurrencyLimit, + limitedBy: mailingLists.endIndex + ) ?? mailingLists.endIndex + let batch = Array(mailingLists[startIndex.. [InboxThreadSummary] { + let response = try await client.execute( + service: .lists, + query: Self.listThreadsQuery, + variables: ["rid": mailingList.rid], + responseType: InboxListThreadsResponse.self + ) + + 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())" + 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)" + ) + return InboxThreadSummary( + rootEmailID: thread.root.id, + rootMessageID: thread.root.messageID, + threadRootEmailIDs: [thread.root.id], + threadRootMessageIDs: [thread.root.messageID], + listID: mailingList.id, + listRID: mailingList.rid, + listName: mailingList.name, + listOwner: mailingList.owner, + subject: thread.subject, + latestSender: thread.sender, + lastActivityAt: thread.updated, + 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) + ) + } + } + + private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { + var grouped: [String: InboxThreadSummary] = [:] + + for thread in threads { + guard let existing = grouped[thread.threadGroupingKey] else { + grouped[thread.threadGroupingKey] = thread + continue + } + + let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing + let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() + let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() + let mergedMessageCount = max( + existing.messageCount ?? existing.threadRootMessageIDs.count, + thread.messageCount ?? thread.threadRootMessageIDs.count, + mergedRootMessageIDs.count + ) + + grouped[thread.threadGroupingKey] = InboxThreadSummary( + rootEmailID: latest.rootEmailID, + rootMessageID: latest.rootMessageID, + threadRootEmailIDs: mergedRootEmailIDs, + threadRootMessageIDs: mergedRootMessageIDs, + listID: latest.listID, + listRID: latest.listRID, + listName: latest.listName, + listOwner: latest.listOwner, + subject: latest.subject, + latestSender: latest.latestSender, + lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), + messageCount: mergedMessageCount, + repo: latest.repo ?? existing.repo, + containsPatch: latest.containsPatch || existing.containsPatch, + isUnread: latest.isUnread || existing.isUnread + ) + } + + return grouped.values.sorted { lhs, rhs in + if lhs.lastActivityAt == rhs.lastActivityAt { + return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending + } + return lhs.lastActivityAt > rhs.lastActivityAt + } + } + + private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { + var seen = Set() + return mailingLists.filter { mailingList in + seen.insert(mailingList.rid).inserted + } + } + + nonisolated static func deriveRepositoryName(from listName: String) -> String? { + let separators = ["-devel", "-patches", "-dev", ".patches"] + for separator in separators where listName.hasSuffix(separator) { + return String(listName.dropLast(separator.count)) + } + return nil + } +} diff --git a/Hutch/Views/Inbox/ThreadDetailView.swift b/Hutch/Views/Inbox/ThreadDetailView.swift new file mode 100644 index 0000000..3c42e7f --- /dev/null +++ b/Hutch/Views/Inbox/ThreadDetailView.swift @@ -0,0 +1,324 @@ +import MessageUI +import os +import SwiftUI +import UIKit + +private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") + +struct ThreadDetailView: View { + let thread: InboxThreadSummary + let onViewed: () -> Void + + @Environment(AppState.self) private var appState + @State private var viewModel: ThreadViewModel? + @State private var replySuccessMessage: String? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading thread…") + } + } + .navigationTitle("Thread") + .navigationBarTitleDisplayMode(.inline) + .task { + if viewModel == nil { + onViewed() + let vm = ThreadViewModel(summary: thread, client: appState.client) + viewModel = vm + await vm.loadThread() + } + } + .sheet(item: Binding( + get: { viewModel?.composeDraft }, + set: { _ in viewModel?.dismissReply() } + )) { draft in + MailComposeView(draft: draft) { result in + switch result { + case .failed(let message): + inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)") + viewModel?.error = message + case .cancelled: + inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)") + case .saved: + inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)") + case .sent: + inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)") + replySuccessMessage = "Reply handed off to Mail." + Task { + await viewModel?.loadThread() + } + } + } + } + .overlay(alignment: .top) { + if let replySuccessMessage { + Text(replySuccessMessage) + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.thinMaterial, in: Capsule()) + .padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: replySuccessMessage) + .onChange(of: replySuccessMessage) { _, message in + guard message != nil else { return } + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + if self.replySuccessMessage == message { + self.replySuccessMessage = nil + } + } + } + } + + @ViewBuilder + private func content(_ viewModel: ThreadViewModel) -> some View { + @Bindable var vm = viewModel + + List { + if let thread = viewModel.thread { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(thread.displaySubject) + .font(.headline) + Text(headerMetadata(thread)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + ForEach(thread.messages) { message in + InboxMessageRow(message: message) + } + } + } + .listStyle(.plain) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Reply") { + viewModel.prepareReply() + } + } + } + .overlay { + if viewModel.isLoading, viewModel.thread == nil { + SRHTLoadingStateView(message: "Loading thread…") + } else if let error = viewModel.error, viewModel.thread == nil { + SRHTErrorStateView( + title: "Couldn't Load Thread", + message: error, + retryAction: { await viewModel.loadThread() } + ) + } + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadThread() + } + } + + private func headerMetadata(_ thread: InboxThreadDetail) -> String { + var parts = [thread.listDisplayName] + if let messageCount = thread.messageCount, messageCount > 1 { + parts.append("\(messageCount) messages") + } + parts.append(thread.lastActivityAt.relativeDescription) + return parts.joined(separator: " • ") + } +} + +private struct InboxMessageRow: View { + let message: InboxMessage + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(senderLine) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + Text(message.date.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if message.isPatch { + Text("Patch") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + } + + ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in + switch block { + case .plainText(let text): + Text(text) + .font(.body) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + case .diff(let diff): + ScrollView(.horizontal) { + DiffView(diff: diff) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .padding(.vertical, 6) + .listRowSeparator(.visible) + } + + private var senderLine: String { + if let email = message.senderEmailAddress, + email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame { + return "\(message.senderDisplayName) <\(email)>" + } + return message.senderDisplayName + } +} + +private struct MailComposeView: UIViewControllerRepresentable { + let draft: MailComposeDraft + let onComplete: (Result) -> Void + + enum Result { + case cancelled + case saved + case sent + case failed(String) + } + + func makeCoordinator() -> Coordinator { + Coordinator(onComplete: onComplete) + } + + func makeUIViewController(context: Context) -> UIViewController { + guard MFMailComposeViewController.canSendMail() else { + let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: { + context.coordinator.onComplete(.failed("Mail is not configured on this device.")) + })) + DispatchQueue.main.async { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + return controller + } + + let controller = MFMailComposeViewController() + controller.mailComposeDelegate = context.coordinator + controller.setToRecipients(draft.recipients) + if !draft.ccRecipients.isEmpty { + controller.setCcRecipients(draft.ccRecipients) + } + if !draft.subject.isEmpty { + controller.setSubject(draft.subject) + } + if !draft.body.isEmpty { + controller.setMessageBody(draft.body, isHTML: false) + } + return controller + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + let onComplete: (Result) -> Void + + init(onComplete: @escaping (Result) -> Void) { + self.onComplete = onComplete + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: Error? + ) { + if error != nil { + let message = error?.localizedDescription ?? "The reply could not be sent." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + return + } + switch result { + case .cancelled: + controller.dismiss(animated: true) + onComplete(.cancelled) + case .saved: + controller.dismiss(animated: true) + onComplete(.saved) + case .sent: + controller.dismiss(animated: true) + onComplete(.sent) + case .failed: + let message = "Mail could not send the reply from the configured iOS Mail account." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + @unknown default: + let message = "Mail returned an unknown result while sending the reply." + presentFailureAlert(on: controller, message: message) + onComplete(.failed(message)) + } + } + + private func presentFailureAlert(on controller: UIViewController, message: String) { + guard controller.presentedViewController == nil else { return } + let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + controller.present(alert, animated: true) + } + } +} + +private final class MailUnavailableViewController: UIViewController { + private let onDismiss: () -> Void + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + navigationItem.title = "Reply" + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissSelf) + ) + + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Mail is not configured on this device." + label.textAlignment = .center + label.numberOfLines = 0 + label.textColor = .secondaryLabel + + view.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + @objc + private func dismissSelf() { + dismiss(animated: true) + onDismiss() + } +} 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 + } +} diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift index b1db464..4380e22 100644 --- a/Hutch/Views/Repositories/DiffView.swift +++ b/Hutch/Views/Repositories/DiffView.swift @@ -9,14 +9,23 @@ struct DiffView: View { let diff: String var body: some View { - let lines = diff.components(separatedBy: "\n") + let lines = normalizedDiff.components(separatedBy: "\n") - LazyVStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in DiffLineView(line: line) } } - .font(.caption.monospaced()) + .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") } } @@ -27,7 +36,6 @@ private struct DiffLineView: View { Text(line.isEmpty ? " " : line) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) - .padding(.vertical, 1) .background(backgroundColor) .foregroundStyle(foregroundColor) .fontWeight(isHeader ? .semibold : .regular) @@ -47,9 +55,9 @@ private struct DiffLineView: View { switch kind { case .added: .green.opacity(0.15) case .removed: .red.opacity(0.15) - case .hunk: .gray.opacity(0.12) - case .fileHeader: .gray.opacity(0.08) - case .meta: .gray.opacity(0.05) + case .hunk: .clear + case .fileHeader: .clear + case .meta: .clear case .context: .clear } } diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift new file mode 100644 index 0000000..b202d03 --- /dev/null +++ b/HutchTests/InboxViewModelTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing +@testable import Hutch + +struct InboxViewModelTests { + + @Test + func derivesRepositoryNameFromCommonPatchListSuffixes() { + #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut") + #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git") + #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil) + } + + @Test + func parsesMailtoDraft() { + let draft = ThreadViewModel.mailComposeDraft( + from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM" + ) + + #expect(draft?.recipients == ["list@example.com"]) + #expect(draft?.ccRecipients == ["author@example.com"]) + #expect(draft?.subject == "Re: PATCH") + #expect(draft?.body == "LGTM") + } + + @Test + func computesLocalUnreadStateFromLastViewedMarker() { + let suiteName = "InboxViewModelTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let threadID = "list#message" + let lastActivity = Date(timeIntervalSince1970: 2_000) + + #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) + + InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults) + #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) + + InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults) + #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) + } + + @Test + func normalizesThreadSubjectsForDisplay() { + let summary = InboxThreadSummary( + rootEmailID: 1, + rootMessageID: "message", + threadRootEmailIDs: [1], + threadRootMessageIDs: ["message"], + listID: 2, + listRID: "list", + listName: "hut-devel", + listOwner: Entity(canonicalName: "~owner"), + subject: "Re: Fwd: [PATCH] test: add parser ", + latestSender: Entity(canonicalName: "~sender"), + lastActivityAt: Date(timeIntervalSince1970: 2_000), + messageCount: 3, + repo: "hut", + containsPatch: true, + isUnread: true + ) + + #expect(summary.displaySubject == "[PATCH] test: add parser") + #expect(summary.metadataLine.contains("~sender")) + #expect(summary.metadataLine.contains("2 replies")) + } + + @Test + func keepsDistinctThreadsDistinctByRootMessageID() { + let baseList = InboxMailingListReference( + id: 1, + rid: "list", + name: "hut-devel", + owner: Entity(canonicalName: "~owner") + ) + + let first = InboxThreadSummary( + rootEmailID: 10, + rootMessageID: "message-1", + threadRootEmailIDs: [10], + threadRootMessageIDs: ["message-1"], + listID: baseList.id, + listRID: baseList.rid, + listName: baseList.name, + listOwner: baseList.owner, + subject: "[PATCH] test", + latestSender: Entity(canonicalName: "~a"), + lastActivityAt: Date(timeIntervalSince1970: 100), + messageCount: 1, + repo: "hut", + containsPatch: true, + isUnread: true + ) + let second = InboxThreadSummary( + rootEmailID: 11, + rootMessageID: "message-2", + threadRootEmailIDs: [11], + threadRootMessageIDs: ["message-2"], + listID: baseList.id, + listRID: baseList.rid, + listName: baseList.name, + listOwner: baseList.owner, + subject: "[PATCH] test", + latestSender: Entity(canonicalName: "~b"), + lastActivityAt: Date(timeIntervalSince1970: 200), + messageCount: 2, + repo: "hut", + containsPatch: true, + isUnread: true + ) + + #expect(first.id != second.id) + } + + @Test + func segmentsPatchBodyAndTreatsSignatureAsPlainText() { + let body = """ + From: Christian Cleberg + + --- + test-patch.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 test-patch.txt + + diff --git a/test-patch.txt b/test-patch.txt + new file mode 100644 + index 0000000..c7b6eed + --- /dev/null + +++ b/test-patch.txt + @@ -0,0 +1 @@ + +test Wed Mar 18 23:19:03 CDT 2026 + -- + 2.50.1 (Apple Git-155) + """ + + let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true) + + #expect(segments.count == 3) + + guard case let .plainText(leadingPlainText) = segments[0] else { + Issue.record("Expected first segment to be plain text") + return + } + #expect(leadingPlainText.contains("From: Christian Cleberg ")) + #expect(leadingPlainText.contains("---")) + #expect(leadingPlainText.contains(" test-patch.txt | 1 +")) + #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)")) + #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt")) + + guard case let .diff(diff) = segments[1] else { + Issue.record("Expected second segment to be diff") + return + } + #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt")) + #expect(diff.contains("--- /dev/null")) + #expect(diff.contains("+++ b/test-patch.txt")) + #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026")) + #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)")) + + guard case let .plainText(trailingPlainText) = segments[2] else { + Issue.record("Expected third segment to be plain text") + return + } + #expect(trailingPlainText.contains("--")) + #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)")) + } +} -- cgit v1.2.3