summaryrefslogtreecommitdiff
path: root/Hutch/Views/Inbox
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Inbox')
-rw-r--r--Hutch/Views/Inbox/InboxView.swift125
-rw-r--r--Hutch/Views/Inbox/InboxViewModel.swift371
-rw-r--r--Hutch/Views/Inbox/ThreadDetailView.swift374
-rw-r--r--Hutch/Views/Inbox/ThreadViewModel.swift760
4 files changed, 1630 insertions, 0 deletions
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..304e62d
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,125 @@
+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)
+ }
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Failed to load inbox",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "Inbox Zero",
+ systemImage: "tray",
+ description: Text("Unread 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)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ viewModel.markThreadRead(thread)
+ }
+ } label: {
+ Label("Mark as Read", systemImage: "envelope.open")
+ }
+ .tint(.blue)
+ }
+}
+
+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..9c1ef45
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxViewModel.swift
@@ -0,0 +1,371 @@
+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
+ .filter(\.isUnread)
+ .sorted { lhs, rhs in
+ if lhs.lastActivityAt == rhs.lastActivityAt {
+ return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
+ }
+ return lhs.lastActivityAt > rhs.lastActivityAt
+ }
+ } catch {
+ 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)
+ inboxListLogger.debug(
+ "Inbox mark read: key=\(thread.id, privacy: .public) latestActivityAt=\(thread.lastActivityAt.ISO8601Format(), privacy: .public) storedLastViewedAt=\(viewedAt.ISO8601Format(), privacy: .public)"
+ )
+ threads.removeAll { $0.id == thread.id }
+ }
+
+ 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] {
+ 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..<endIndex])
+
+ let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
+ for mailingList in batch {
+ group.addTask {
+ do {
+ return (try await self.fetchThreads(for: mailingList), nil)
+ } catch {
+ return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
+ }
+ }
+ }
+
+ var batchSummaries: [InboxThreadSummary] = []
+ var batchFailures: [String] = []
+ for await result in group {
+ batchSummaries.append(contentsOf: result.0)
+ if let failure = result.1 {
+ batchFailures.append(failure)
+ }
+ }
+ return (batchSummaries, batchFailures)
+ }
+
+ 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
+ }
+
+ if summaries.isEmpty, let firstFailure = failureMessages.first {
+ throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
+ }
+
+ return deduplicateThreads(summaries)
+ }
+
+ private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [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 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,
+ 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: isUnread
+ )
+ }
+ }
+
+ 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 updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ let current = threads[index]
+ if !isUnread {
+ threads.remove(at: index)
+ return
+ }
+ threads[index] = InboxThreadSummary(
+ rootEmailID: current.rootEmailID,
+ rootMessageID: current.rootMessageID,
+ threadRootEmailIDs: current.threadRootEmailIDs,
+ threadRootMessageIDs: current.threadRootMessageIDs,
+ listID: current.listID,
+ listRID: current.listRID,
+ listName: current.listName,
+ listOwner: current.listOwner,
+ subject: current.subject,
+ latestSender: current.latestSender,
+ lastActivityAt: current.lastActivityAt,
+ messageCount: current.messageCount,
+ repo: current.repo,
+ containsPatch: current.containsPatch,
+ isUnread: isUnread
+ )
+ }
+
+ private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
+ var seen = Set<String>()
+ return mailingLists.filter { mailingList in
+ 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..6fe835c
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadDetailView.swift
@@ -0,0 +1,374 @@
+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
+ var onMarkRead: (() -> Void)? = nil
+ var onMarkUnread: (() -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: ThreadViewModel?
+ @State private var replySuccessMessage: String?
+ @State private var loadedThreadID: String?
+ @State private var hasMarkedCurrentThreadViewed = false
+ @State private var suppressAutoMarkViewed = false
+ @State private var isUnread: Bool
+
+ init(
+ thread: InboxThreadSummary,
+ onViewed: @escaping () -> Void,
+ onMarkRead: (() -> Void)? = nil,
+ onMarkUnread: (() -> Void)? = nil
+ ) {
+ self.thread = thread
+ self.onViewed = onViewed
+ self.onMarkRead = onMarkRead
+ self.onMarkUnread = onMarkUnread
+ self._isUnread = State(initialValue: thread.isUnread)
+ }
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading thread…")
+ }
+ }
+ .navigationTitle("Thread")
+ .navigationBarTitleDisplayMode(.inline)
+ .task(id: thread.id) {
+ guard loadedThreadID != thread.id else { return }
+ let vm = ThreadViewModel(summary: thread, client: appState.client)
+ viewModel = vm
+ loadedThreadID = thread.id
+ hasMarkedCurrentThreadViewed = false
+ suppressAutoMarkViewed = false
+ isUnread = thread.isUnread
+ await vm.loadThread()
+ }
+ .onChange(of: viewModel?.thread?.id) { _, threadID in
+ guard threadID != nil, !hasMarkedCurrentThreadViewed, !suppressAutoMarkViewed else { return }
+ hasMarkedCurrentThreadViewed = true
+ isUnread = false
+ onViewed()
+ }
+ .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)
+ }
+
+ if let partialWarning = viewModel.partialWarning {
+ Section {
+ Text(partialWarning)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ ForEach(thread.messages) { message in
+ InboxMessageRow(message: message)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ if onMarkRead != nil || onMarkUnread != nil {
+ Button(isUnread ? "Mark Read" : "Mark Unread") {
+ suppressAutoMarkViewed = !isUnread
+ if isUnread {
+ onMarkRead?()
+ isUnread = false
+ } else {
+ onMarkUnread?()
+ isUnread = true
+ }
+ }
+ }
+
+ 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: "Failed to 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..c042fba
--- /dev/null
+++ b/Hutch/Views/Inbox/ThreadViewModel.swift
@@ -0,0 +1,760 @@
+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 partialWarning: 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
+ partialWarning = 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] = [:]
+
+ var hadPartialReplyFailure = false
+
+ for payload in threadPayloads {
+ guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
+ continue
+ }
+ messagesByID[rootMessage.id] = rootMessage
+
+ 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)"
+ )
+ }
+ }
+
+ 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
+ )
+ if hadPartialReplyFailure {
+ partialWarning = "Some replies could not be loaded."
+ }
+ } catch {
+ 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)")
+ }
+ }
+
+ private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
+ var payloads: [InboxThreadPayloadDetail] = []
+ var seenRoots = Set<String>()
+
+ 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
+ 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 {
+ return nil
+ }
+
+ let candidates = threadPage.results.map { payload in
+ "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
+ }.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
+ 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
+ }
+ }
+
+ 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)..<end]).trimmingCharacters(in: .whitespaces)
+ let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !name.isEmpty {
+ return (name, email.isEmpty ? nil : email)
+ }
+ return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
+ }
+
+ if trimmedFromLine.contains("@") {
+ return (trimmedFromLine, trimmedFromLine)
+ }
+
+ return (trimmedFromLine, nil)
+ }
+
+ private static func fallbackSenderIdentity(from author: Entity) -> (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 normalizedBody = normalizeLineEndings(in: body)
+ let lines = normalizedBody.components(separatedBy: "\n")
+ 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 stripLeadingFromLineIfPresent(in: normalizedBody)
+ }
+
+ 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[..<diffStartIndex]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !leadingPlainText.isEmpty {
+ blocks.append(.plainText(leadingPlainText))
+ }
+
+ let remainingLines = Array(lines[diffStartIndex...])
+ let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
+
+ let diffLines: ArraySlice<String>
+ let trailingPlainText: String
+ if let signatureIndex {
+ diffLines = remainingLines[..<signatureIndex]
+ trailingPlainText = remainingLines[signatureIndex...]
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ diffLines = remainingLines[...]
+ trailingPlainText = ""
+ }
+
+ let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !diff.isEmpty {
+ blocks.append(.diff(diff))
+ }
+
+ if !trailingPlainText.isEmpty {
+ blocks.append(.plainText(trailingPlainText))
+ }
+ return blocks
+ }
+
+ private nonisolated static func actualDiffStartIndex(in lines: [String]) -> 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 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)
+ 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<T: Decodable>(
+ 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) ?? "<non-utf8 response>"
+ inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
+ #endif
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ let envelope = try decoder.decode(GraphQLResponse<T>.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
+ }
+
+ 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") }
+ }
+}