Commit 787436c54e
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +14
| @@ -7,6 +7,15 @@ import WebKit | ||
| 7 | 7 | @MainActor |
| 8 | 8 | final class AppState { |
| 9 | 9 | |
| 10 | enum Tab: Hashable { | |
| 11 | case home | |
| 12 | case inbox | |
| 13 | case repositories | |
| 14 | case builds | |
| 15 | case tickets | |
| 16 | case settings | |
| 17 | } | |
| 18 | ||
| 10 | 19 | enum AuthPhase { |
| 11 | 20 | /// App just launched, checking for an existing token. |
| 12 | 21 | case launching |
| @@ -25,6 +34,8 @@ final class AppState { | ||
| 25 | 34 | authPhase == .authenticated && currentUser != nil |
| 26 | 35 | } |
| 27 | 36 | |
| 37 | var selectedTab: Tab = .home | |
| 38 | ||
| 28 | 39 | // MARK: - Current user (populated after successful validation) |
| 29 | 40 | |
| 30 | 41 | private(set) var currentUser: User? |
| @@ -94,6 +105,7 @@ final class AppState { | ||
| 94 | 105 | await clearWebData() |
| 95 | 106 | clearWebContentRenderCaches() |
| 96 | 107 | authPhase = .unauthenticated |
| 108 | selectedTab = .home | |
| 97 | 109 | } |
| 98 | 110 | |
| 99 | 111 | func resetAppData() async { |
| @@ -108,6 +120,7 @@ final class AppState { | ||
| 108 | 120 | clearWebContentRenderCaches() |
| 109 | 121 | |
| 110 | 122 | authPhase = .unauthenticated |
| 123 | selectedTab = .home | |
| 111 | 124 | } |
| 112 | 125 | |
| 113 | 126 | // MARK: - Deep link resolution |
| @@ -208,6 +221,7 @@ final class AppState { | ||
| 208 | 221 | client.responseCache.clear() |
| 209 | 222 | currentUser = nil |
| 210 | 223 | pendingDeepLink = nil |
| 224 | selectedTab = .home | |
| 211 | 225 | } |
| 212 | 226 | |
| 213 | 227 | private func clearWebData() async { |
Hutch/App/RootView.swift +22 −20
| @@ -4,17 +4,8 @@ import SwiftUI | ||
| 4 | 4 | /// full-screen sheet for token entry on first launch. |
| 5 | 5 | struct RootView: View { |
| 6 | 6 | @Environment(AppState.self) private var appState |
| 7 | ||
| 8 | enum Tab: Hashable { | |
| 9 | case home | |
| 10 | case repositories | |
| 11 | case builds | |
| 12 | case tickets | |
| 13 | case settings | |
| 14 | } | |
| 15 | ||
| 16 | @State private var selectedTab: Tab = .home | |
| 17 | 7 | @State private var homePath = NavigationPath() |
| 8 | @State private var inboxPath = NavigationPath() | |
| 18 | 9 | @State private var repoPath = NavigationPath() |
| 19 | 10 | @State private var buildsPath = NavigationPath() |
| 20 | 11 | @State private var ticketsPath = NavigationPath() |
| @@ -48,19 +39,29 @@ struct RootView: View { | ||
| 48 | 39 | // MARK: - Tab View |
| 49 | 40 | |
| 50 | 41 | private var tabContent: some View { |
| 51 | TabView(selection: $selectedTab) { | |
| 42 | @Bindable var appState = appState | |
| 43 | ||
| 44 | return TabView(selection: $appState.selectedTab) { | |
| 52 | 45 | NavigationStack(path: $homePath) { |
| 53 | 46 | HomeView() |
| 54 | 47 | } |
| 55 | .tag(Tab.home) | |
| 48 | .tag(AppState.Tab.home) | |
| 56 | 49 | .tabItem { |
| 57 | 50 | Label("Home", systemImage: "house") |
| 58 | 51 | } |
| 59 | 52 | |
| 53 | NavigationStack(path: $inboxPath) { | |
| 54 | InboxView() | |
| 55 | } | |
| 56 | .tag(AppState.Tab.inbox) | |
| 57 | .tabItem { | |
| 58 | Label("Inbox", systemImage: "tray") | |
| 59 | } | |
| 60 | ||
| 60 | 61 | NavigationStack(path: $repoPath) { |
| 61 | 62 | RepositoryListView() |
| 62 | 63 | } |
| 63 | .tag(Tab.repositories) | |
| 64 | .tag(AppState.Tab.repositories) | |
| 64 | 65 | .tabItem { |
| 65 | 66 | Label("Repositories", systemImage: "book.closed") |
| 66 | 67 | } |
| @@ -73,7 +74,7 @@ struct RootView: View { | ||
| 73 | 74 | BuildDetailView(jobId: jobId) |
| 74 | 75 | } |
| 75 | 76 | } |
| 76 | .tag(Tab.builds) | |
| 77 | .tag(AppState.Tab.builds) | |
| 77 | 78 | .tabItem { |
| 78 | 79 | Label("Builds", systemImage: "hammer") |
| 79 | 80 | } |
| @@ -85,13 +86,13 @@ struct RootView: View { | ||
| 85 | 86 | TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) |
| 86 | 87 | } |
| 87 | 88 | } |
| 88 | .tag(Tab.tickets) | |
| 89 | .tag(AppState.Tab.tickets) | |
| 89 | 90 | .tabItem { |
| 90 | 91 | Label("Tickets", systemImage: "ticket") |
| 91 | 92 | } |
| 92 | 93 | |
| 93 | 94 | SettingsView() |
| 94 | .tag(Tab.settings) | |
| 95 | .tag(AppState.Tab.settings) | |
| 95 | 96 | .tabItem { |
| 96 | 97 | Label("Settings", systemImage: "gear") |
| 97 | 98 | } |
| @@ -117,10 +118,11 @@ struct RootView: View { | ||
| 117 | 118 | break |
| 118 | 119 | case .unauthenticated: |
| 119 | 120 | homePath = NavigationPath() |
| 121 | inboxPath = NavigationPath() | |
| 120 | 122 | repoPath = NavigationPath() |
| 121 | 123 | buildsPath = NavigationPath() |
| 122 | 124 | ticketsPath = NavigationPath() |
| 123 | selectedTab = .home | |
| 125 | appState.selectedTab = .home | |
| 124 | 126 | isResolvingDeepLink = false |
| 125 | 127 | case .authenticated: |
| 126 | 128 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) |
| @@ -143,7 +145,7 @@ struct RootView: View { | ||
| 143 | 145 | case .build(let jobId): |
| 144 | 146 | // Reset the builds navigation and push the detail |
| 145 | 147 | buildsPath = NavigationPath() |
| 146 | selectedTab = .builds | |
| 148 | appState.selectedTab = .builds | |
| 147 | 149 | // Defer the push slightly so the tab switch takes effect |
| 148 | 150 | Task { @MainActor in |
| 149 | 151 | try? await Task.sleep(for: .milliseconds(100)) |
| @@ -162,7 +164,7 @@ struct RootView: View { | ||
| 162 | 164 | do { |
| 163 | 165 | let summary = try await appState.resolveRepository(owner: owner, name: repo) |
| 164 | 166 | repoPath = NavigationPath() |
| 165 | selectedTab = .repositories | |
| 167 | appState.selectedTab = .repositories | |
| 166 | 168 | try? await Task.sleep(for: .milliseconds(100)) |
| 167 | 169 | repoPath.append(summary) |
| 168 | 170 | } catch { |
| @@ -178,7 +180,7 @@ struct RootView: View { | ||
| 178 | 180 | do { |
| 179 | 181 | let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) |
| 180 | 182 | ticketsPath = NavigationPath() |
| 181 | selectedTab = .tickets | |
| 183 | appState.selectedTab = .tickets | |
| 182 | 184 | try? await Task.sleep(for: .milliseconds(100)) |
| 183 | 185 | ticketsPath.append(trackerSummary) |
| 184 | 186 | try? await Task.sleep(for: .milliseconds(100)) |
Hutch/Models/Inbox.swift added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | struct InboxThreadSummary: Identifiable, Hashable, Sendable { | |
| 4 | let rootEmailID: Int | |
| 5 | let rootMessageID: String | |
| 6 | let threadRootEmailIDs: [Int] | |
| 7 | let threadRootMessageIDs: [String] | |
| 8 | let listID: Int | |
| 9 | let listRID: String | |
| 10 | let listName: String | |
| 11 | let listOwner: Entity | |
| 12 | let subject: String | |
| 13 | let latestSender: Entity | |
| 14 | let lastActivityAt: Date | |
| 15 | let messageCount: Int? | |
| 16 | let repo: String? | |
| 17 | let containsPatch: Bool | |
| 18 | let isUnread: Bool | |
| 19 | ||
| 20 | var id: String { | |
| 21 | threadGroupingKey | |
| 22 | } | |
| 23 | ||
| 24 | var listDisplayName: String { | |
| 25 | "\(listOwner.canonicalName)/\(listName)" | |
| 26 | } | |
| 27 | ||
| 28 | var displaySubject: String { | |
| 29 | Self.normalizedSubject(from: subject) | |
| 30 | } | |
| 31 | ||
| 32 | var metadataLine: String { | |
| 33 | var parts = [latestSenderDisplayName] | |
| 34 | if let messageCount, messageCount > 1 { | |
| 35 | let replyCount = max(messageCount - 1, 1) | |
| 36 | parts.append("\(replyCount) repl\(replyCount == 1 ? "y" : "ies")") | |
| 37 | } | |
| 38 | parts.append(lastActivityAt.relativeDescription) | |
| 39 | return parts.joined(separator: " • ") | |
| 40 | } | |
| 41 | ||
| 42 | var latestSenderDisplayName: String { | |
| 43 | let canonicalName = latestSender.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 44 | if canonicalName.contains("@") { | |
| 45 | return canonicalName | |
| 46 | } | |
| 47 | return canonicalName | |
| 48 | } | |
| 49 | ||
| 50 | var debugIdentifierSummary: String { | |
| 51 | "subject=\(subject) listRID=\(listRID) listID=\(listID) rootEmailID=\(rootEmailID) rootMessageID=\(rootMessageID) groupingKey=\(threadGroupingKey)" | |
| 52 | } | |
| 53 | ||
| 54 | var threadGroupingKey: String { | |
| 55 | "\(listRID)#\(displaySubject.lowercased())" | |
| 56 | } | |
| 57 | ||
| 58 | private static func normalizedSubject(from subject: String) -> String { | |
| 59 | let collapsedWhitespace = subject | |
| 60 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | |
| 61 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 62 | ||
| 63 | let pattern = #"^(?:(?:re|fwd?)\s*:\s*)+"# | |
| 64 | return collapsedWhitespace.replacingOccurrences( | |
| 65 | of: pattern, | |
| 66 | with: "", | |
| 67 | options: [.regularExpression, .caseInsensitive] | |
| 68 | ) | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | struct InboxMessage: Identifiable, Hashable, Sendable { | |
| 73 | let id: Int | |
| 74 | let author: Entity | |
| 75 | let date: Date | |
| 76 | let subject: String | |
| 77 | let body: String | |
| 78 | let senderDisplayName: String | |
| 79 | let senderEmailAddress: String? | |
| 80 | let isPatch: Bool | |
| 81 | let contentBlocks: [InboxMessageContentBlock] | |
| 82 | let rawMessageURL: URL? | |
| 83 | } | |
| 84 | ||
| 85 | enum InboxMessageContentBlock: Hashable, Sendable { | |
| 86 | case plainText(String) | |
| 87 | case diff(String) | |
| 88 | } | |
| 89 | ||
| 90 | struct InboxThreadDetail: Sendable { | |
| 91 | let id: String | |
| 92 | let rootEmailID: Int | |
| 93 | let rootMessageID: String | |
| 94 | let subject: String | |
| 95 | let author: Entity | |
| 96 | let lastActivityAt: Date | |
| 97 | let mailto: String? | |
| 98 | let listID: Int | |
| 99 | let listRID: String | |
| 100 | let listName: String | |
| 101 | let listOwner: Entity | |
| 102 | let messageCount: Int? | |
| 103 | let messages: [InboxMessage] | |
| 104 | ||
| 105 | var listDisplayName: String { | |
| 106 | "\(listOwner.canonicalName)/\(listName)" | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | extension InboxThreadDetail { | |
| 111 | var replyRecipient: String { | |
| 112 | "\(listOwner.canonicalName)/\(listName)@lists.sr.ht" | |
| 113 | } | |
| 114 | ||
| 115 | var replySubject: String { | |
| 116 | subject.lowercased().hasPrefix("re:") ? subject : "Re: \(subject)" | |
| 117 | } | |
| 118 | ||
| 119 | var displaySubject: String { | |
| 120 | InboxThreadSummary( | |
| 121 | rootEmailID: rootEmailID, | |
| 122 | rootMessageID: rootMessageID, | |
| 123 | threadRootEmailIDs: [rootEmailID], | |
| 124 | threadRootMessageIDs: [rootMessageID], | |
| 125 | listID: listID, | |
| 126 | listRID: listRID, | |
| 127 | listName: listName, | |
| 128 | listOwner: listOwner, | |
| 129 | subject: subject, | |
| 130 | latestSender: author, | |
| 131 | lastActivityAt: lastActivityAt, | |
| 132 | messageCount: messageCount, | |
| 133 | repo: nil, | |
| 134 | containsPatch: messages.contains(where: \.isPatch), | |
| 135 | isUnread: false | |
| 136 | ).displaySubject | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | struct MailComposeDraft: Sendable { | |
| 141 | let recipients: [String] | |
| 142 | let ccRecipients: [String] | |
| 143 | let subject: String | |
| 144 | let body: String | |
| 145 | ||
| 146 | var id: String { | |
| 147 | ([subject] + recipients + ccRecipients).joined(separator: "|") | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | extension MailComposeDraft: Identifiable {} | |
| 152 | ||
| 153 | struct InboxMailingListReference: Decodable, Sendable, Hashable { | |
| 154 | let id: Int | |
| 155 | let rid: String | |
| 156 | let name: String | |
| 157 | let owner: Entity | |
| 158 | } | |
| 159 | ||
| 160 | struct InboxPatchPreview: Decodable, Sendable, Hashable { | |
| 161 | let subject: String? | |
| 162 | } | |
| 163 | ||
| 164 | enum InboxReadStateStore { | |
| 165 | private static let key = "InboxThreadLastViewed" | |
| 166 | ||
| 167 | static func lastViewedAt(for threadID: String, defaults: UserDefaults = .standard) -> Date? { | |
| 168 | guard let dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval], | |
| 169 | let timestamp = dictionary[threadID] else { | |
| 170 | return nil | |
| 171 | } | |
| 172 | return Date(timeIntervalSince1970: timestamp) | |
| 173 | } | |
| 174 | ||
| 175 | static func markViewed(_ date: Date, for threadID: String, defaults: UserDefaults = .standard) { | |
| 176 | var dictionary = defaults.dictionary(forKey: key) as? [String: TimeInterval] ?? [:] | |
| 177 | dictionary[threadID] = date.timeIntervalSince1970 | |
| 178 | defaults.set(dictionary, forKey: key) | |
| 179 | } | |
| 180 | ||
| 181 | static func isUnread(threadID: String, lastActivityAt: Date, defaults: UserDefaults = .standard) -> Bool { | |
| 182 | guard let lastViewedAt = lastViewedAt(for: threadID, defaults: defaults) else { | |
| 183 | return true | |
| 184 | } | |
| 185 | return lastActivityAt > lastViewedAt | |
| 186 | } | |
| 187 | } | |
Hutch/Views/Home/HomeView.swift +23 −2
| @@ -115,8 +115,8 @@ struct HomeView: View { | ||
| 115 | 115 | } |
| 116 | 116 | } |
| 117 | 117 | } header: { |
| 118 | HomeSectionHeader("Recent Builds") { | |
| 119 | BuildListView() | |
| 118 | HomeSectionActionHeader("Recent Builds") { | |
| 119 | appState.selectedTab = .builds | |
| 120 | 120 | } |
| 121 | 121 | } |
| 122 | 122 | } |
| @@ -243,6 +243,27 @@ private struct HomeSectionHeader<Destination: View>: View { | ||
| 243 | 243 | } |
| 244 | 244 | } |
| 245 | 245 | |
| 246 | private struct HomeSectionActionHeader: View { | |
| 247 | let title: String | |
| 248 | let action: () -> Void | |
| 249 | ||
| 250 | init(_ title: String, action: @escaping () -> Void) { | |
| 251 | self.title = title | |
| 252 | self.action = action | |
| 253 | } | |
| 254 | ||
| 255 | var body: some View { | |
| 256 | HStack { | |
| 257 | Text(title) | |
| 258 | Spacer() | |
| 259 | Button("See All", action: action) | |
| 260 | .font(.caption.weight(.medium)) | |
| 261 | .buttonStyle(.plain) | |
| 262 | } | |
| 263 | .textCase(nil) | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 246 | 267 | private struct HomeAssignedTicketsListView: View { |
| 247 | 268 | let viewModel: HomeViewModel |
| 248 | 269 | |
Hutch/Views/Inbox/InboxView.swift added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct InboxView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @State private var viewModel: InboxViewModel? | |
| 6 | ||
| 7 | var body: some View { | |
| 8 | Group { | |
| 9 | if let viewModel { | |
| 10 | listContent(viewModel) | |
| 11 | } else { | |
| 12 | SRHTLoadingStateView(message: "Loading inbox…") | |
| 13 | } | |
| 14 | } | |
| 15 | .navigationTitle("Inbox") | |
| 16 | .task { | |
| 17 | if viewModel == nil { | |
| 18 | let vm = InboxViewModel(client: appState.client) | |
| 19 | viewModel = vm | |
| 20 | await vm.loadThreads() | |
| 21 | } | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | @ViewBuilder | |
| 26 | private func listContent(_ viewModel: InboxViewModel) -> some View { | |
| 27 | @Bindable var vm = viewModel | |
| 28 | ||
| 29 | List { | |
| 30 | ForEach(viewModel.threads) { thread in | |
| 31 | NavigationLink(value: thread) { | |
| 32 | InboxThreadRow(thread: thread) | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | .listStyle(.plain) | |
| 37 | .overlay { | |
| 38 | if viewModel.isLoading, viewModel.threads.isEmpty { | |
| 39 | SRHTLoadingStateView(message: "Loading inbox…") | |
| 40 | } else if let error = viewModel.error, viewModel.threads.isEmpty { | |
| 41 | SRHTErrorStateView( | |
| 42 | title: "Couldn't Load Threads", | |
| 43 | message: error, | |
| 44 | retryAction: { await viewModel.loadThreads() } | |
| 45 | ) | |
| 46 | } else if viewModel.threads.isEmpty, viewModel.error == nil { | |
| 47 | ContentUnavailableView( | |
| 48 | "No Threads", | |
| 49 | systemImage: "tray", | |
| 50 | description: Text("Patch threads will appear here.") | |
| 51 | ) | |
| 52 | } | |
| 53 | } | |
| 54 | .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) { | |
| 55 | await viewModel.loadThreads() | |
| 56 | } | |
| 57 | .srhtErrorBanner(error: $vm.error) | |
| 58 | .refreshable { | |
| 59 | await viewModel.loadThreads() | |
| 60 | } | |
| 61 | .navigationDestination(for: InboxThreadSummary.self) { thread in | |
| 62 | ThreadDetailView(thread: thread) { | |
| 63 | viewModel.markThreadRead(thread) | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | private struct InboxThreadRow: View { | |
| 70 | let thread: InboxThreadSummary | |
| 71 | ||
| 72 | var body: some View { | |
| 73 | HStack(alignment: .top, spacing: 12) { | |
| 74 | Circle() | |
| 75 | .fill(thread.isUnread ? .blue : .clear) | |
| 76 | .frame(width: 8, height: 8) | |
| 77 | .padding(.top, 6) | |
| 78 | ||
| 79 | VStack(alignment: .leading, spacing: 4) { | |
| 80 | Text(thread.displaySubject) | |
| 81 | .font(.subheadline.weight(thread.isUnread ? .semibold : .medium)) | |
| 82 | .lineLimit(2) | |
| 83 | ||
| 84 | HStack(spacing: 8) { | |
| 85 | if thread.containsPatch { | |
| 86 | Image(systemName: "arrow.triangle.branch") | |
| 87 | .font(.caption) | |
| 88 | .foregroundStyle(.secondary) | |
| 89 | } | |
| 90 | ||
| 91 | Text(thread.metadataLine) | |
| 92 | .font(.caption) | |
| 93 | .foregroundStyle(.secondary) | |
| 94 | .lineLimit(1) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | Spacer(minLength: 8) | |
| 99 | ||
| 100 | Text(thread.lastActivityAt.relativeDescription) | |
| 101 | .font(.caption) | |
| 102 | .foregroundStyle(.tertiary.opacity(0.7)) | |
| 103 | .lineLimit(1) | |
| 104 | } | |
| 105 | .padding(.vertical, 2) | |
| 106 | } | |
| 107 | } | |
Hutch/Views/Inbox/InboxViewModel.swift added +335
| @@ -0,0 +1,335 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList") | |
| 5 | ||
| 6 | private struct InboxSubscriptionsResponse: Decodable, Sendable { | |
| 7 | let subscriptions: InboxSubscriptionPage | |
| 8 | } | |
| 9 | ||
| 10 | private struct InboxSubscriptionPage: Decodable, Sendable { | |
| 11 | let results: [InboxActivitySubscription] | |
| 12 | let cursor: String? | |
| 13 | } | |
| 14 | ||
| 15 | private struct InboxActivitySubscription: Decodable, Sendable { | |
| 16 | let id: Int | |
| 17 | let created: Date | |
| 18 | let list: InboxMailingListReference? | |
| 19 | ||
| 20 | enum CodingKeys: String, CodingKey { | |
| 21 | case id | |
| 22 | case created | |
| 23 | case list | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | private struct InboxListThreadsResponse: Decodable, Sendable { | |
| 28 | let list: InboxMailingListThreads | |
| 29 | } | |
| 30 | ||
| 31 | private struct InboxMailingListThreads: Decodable, Sendable { | |
| 32 | let threads: InboxThreadPage | |
| 33 | } | |
| 34 | ||
| 35 | private struct InboxThreadPage: Decodable, Sendable { | |
| 36 | let results: [InboxThreadPayload] | |
| 37 | let cursor: String? | |
| 38 | } | |
| 39 | ||
| 40 | private struct InboxThreadPayload: Decodable, Sendable { | |
| 41 | let created: Date | |
| 42 | let updated: Date | |
| 43 | let subject: String | |
| 44 | let replies: Int | |
| 45 | let sender: Entity | |
| 46 | let root: InboxEmailPreview | |
| 47 | } | |
| 48 | ||
| 49 | private struct InboxEmailPreview: Decodable, Sendable { | |
| 50 | let id: Int | |
| 51 | let subject: String | |
| 52 | let date: Date? | |
| 53 | let received: Date | |
| 54 | let messageID: String | |
| 55 | let body: String | |
| 56 | let patch: InboxPatchPreview? | |
| 57 | } | |
| 58 | ||
| 59 | @Observable | |
| 60 | @MainActor | |
| 61 | final class InboxViewModel { | |
| 62 | private(set) var threads: [InboxThreadSummary] = [] | |
| 63 | private(set) var isLoading = false | |
| 64 | var error: String? | |
| 65 | ||
| 66 | private let client: SRHTClient | |
| 67 | private let listThreadFetchLimit = 10 | |
| 68 | private let listFetchConcurrencyLimit = 4 | |
| 69 | ||
| 70 | private static let subscriptionsQuery = """ | |
| 71 | query inboxSubscriptions($cursor: Cursor) { | |
| 72 | subscriptions(cursor: $cursor) { | |
| 73 | results { | |
| 74 | ... on MailingListSubscription { | |
| 75 | id | |
| 76 | created | |
| 77 | list { | |
| 78 | id | |
| 79 | rid | |
| 80 | name | |
| 81 | owner { canonicalName } | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | cursor | |
| 86 | } | |
| 87 | } | |
| 88 | """ | |
| 89 | ||
| 90 | private static let listThreadsQuery = """ | |
| 91 | query inboxListThreads($rid: ID!, $cursor: Cursor) { | |
| 92 | list(rid: $rid) { | |
| 93 | threads(cursor: $cursor) { | |
| 94 | results { | |
| 95 | created | |
| 96 | updated | |
| 97 | subject | |
| 98 | replies | |
| 99 | sender { canonicalName } | |
| 100 | root { | |
| 101 | id | |
| 102 | subject | |
| 103 | date | |
| 104 | received | |
| 105 | messageID | |
| 106 | body | |
| 107 | patch { subject } | |
| 108 | } | |
| 109 | } | |
| 110 | cursor | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | """ | |
| 115 | ||
| 116 | init(client: SRHTClient) { | |
| 117 | self.client = client | |
| 118 | } | |
| 119 | ||
| 120 | func loadThreads() async { | |
| 121 | guard !isLoading else { return } | |
| 122 | isLoading = true | |
| 123 | error = nil | |
| 124 | defer { isLoading = false } | |
| 125 | ||
| 126 | do { | |
| 127 | let subscriptions = try await fetchSubscriptions() | |
| 128 | let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list)) | |
| 129 | let fetchedThreads = try await fetchThreads(for: mailingLists) | |
| 130 | threads = fetchedThreads.sorted { lhs, rhs in | |
| 131 | if lhs.lastActivityAt == rhs.lastActivityAt { | |
| 132 | return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending | |
| 133 | } | |
| 134 | return lhs.lastActivityAt > rhs.lastActivityAt | |
| 135 | } | |
| 136 | } catch { | |
| 137 | threads = [] | |
| 138 | self.error = error.localizedDescription | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | func markThreadRead(_ thread: InboxThreadSummary) { | |
| 143 | let viewedAt = max(Date(), thread.lastActivityAt) | |
| 144 | InboxReadStateStore.markViewed(viewedAt, for: thread.id) | |
| 145 | guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return } | |
| 146 | let current = threads[index] | |
| 147 | threads[index] = InboxThreadSummary( | |
| 148 | rootEmailID: current.rootEmailID, | |
| 149 | rootMessageID: current.rootMessageID, | |
| 150 | threadRootEmailIDs: current.threadRootEmailIDs, | |
| 151 | threadRootMessageIDs: current.threadRootMessageIDs, | |
| 152 | listID: current.listID, | |
| 153 | listRID: current.listRID, | |
| 154 | listName: current.listName, | |
| 155 | listOwner: current.listOwner, | |
| 156 | subject: current.subject, | |
| 157 | latestSender: current.latestSender, | |
| 158 | lastActivityAt: current.lastActivityAt, | |
| 159 | messageCount: current.messageCount, | |
| 160 | repo: current.repo, | |
| 161 | containsPatch: current.containsPatch, | |
| 162 | isUnread: false | |
| 163 | ) | |
| 164 | } | |
| 165 | ||
| 166 | private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { | |
| 167 | var subscriptions: [InboxActivitySubscription] = [] | |
| 168 | var cursor: String? | |
| 169 | ||
| 170 | while true { | |
| 171 | var variables: [String: any Sendable] = [:] | |
| 172 | if let cursor { | |
| 173 | variables["cursor"] = cursor | |
| 174 | } | |
| 175 | ||
| 176 | let response = try await client.execute( | |
| 177 | service: .lists, | |
| 178 | query: Self.subscriptionsQuery, | |
| 179 | variables: variables.isEmpty ? nil : variables, | |
| 180 | responseType: InboxSubscriptionsResponse.self | |
| 181 | ) | |
| 182 | ||
| 183 | subscriptions.append(contentsOf: response.subscriptions.results) | |
| 184 | guard let nextCursor = response.subscriptions.cursor else { | |
| 185 | break | |
| 186 | } | |
| 187 | cursor = nextCursor | |
| 188 | } | |
| 189 | ||
| 190 | return subscriptions | |
| 191 | } | |
| 192 | ||
| 193 | private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] { | |
| 194 | guard !mailingLists.isEmpty else { return [] } | |
| 195 | ||
| 196 | var summaries: [InboxThreadSummary] = [] | |
| 197 | var startIndex = mailingLists.startIndex | |
| 198 | var failureMessages: [String] = [] | |
| 199 | ||
| 200 | while startIndex < mailingLists.endIndex { | |
| 201 | let endIndex = mailingLists.index( | |
| 202 | startIndex, | |
| 203 | offsetBy: listFetchConcurrencyLimit, | |
| 204 | limitedBy: mailingLists.endIndex | |
| 205 | ) ?? mailingLists.endIndex | |
| 206 | let batch = Array(mailingLists[startIndex..<endIndex]) | |
| 207 | ||
| 208 | let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in | |
| 209 | for mailingList in batch { | |
| 210 | group.addTask { | |
| 211 | do { | |
| 212 | return (try await self.fetchThreads(for: mailingList), nil) | |
| 213 | } catch { | |
| 214 | return ([], error.localizedDescription) | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | var batchSummaries: [InboxThreadSummary] = [] | |
| 220 | var batchFailures: [String] = [] | |
| 221 | for await result in group { | |
| 222 | batchSummaries.append(contentsOf: result.0) | |
| 223 | if let failure = result.1 { | |
| 224 | batchFailures.append(failure) | |
| 225 | } | |
| 226 | } | |
| 227 | return (batchSummaries, batchFailures) | |
| 228 | } | |
| 229 | ||
| 230 | summaries.append(contentsOf: batchResult.0) | |
| 231 | failureMessages.append(contentsOf: batchResult.1) | |
| 232 | startIndex = endIndex | |
| 233 | } | |
| 234 | ||
| 235 | if summaries.isEmpty, let firstFailure = failureMessages.first { | |
| 236 | throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)]) | |
| 237 | } | |
| 238 | ||
| 239 | return deduplicateThreads(summaries) | |
| 240 | } | |
| 241 | ||
| 242 | private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] { | |
| 243 | let response = try await client.execute( | |
| 244 | service: .lists, | |
| 245 | query: Self.listThreadsQuery, | |
| 246 | variables: ["rid": mailingList.rid], | |
| 247 | responseType: InboxListThreadsResponse.self | |
| 248 | ) | |
| 249 | ||
| 250 | return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in | |
| 251 | let threadID = "\(mailingList.rid)#\(thread.root.messageID)" | |
| 252 | 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())" | |
| 253 | inboxListLogger.debug( | |
| 254 | "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)" | |
| 255 | ) | |
| 256 | return InboxThreadSummary( | |
| 257 | rootEmailID: thread.root.id, | |
| 258 | rootMessageID: thread.root.messageID, | |
| 259 | threadRootEmailIDs: [thread.root.id], | |
| 260 | threadRootMessageIDs: [thread.root.messageID], | |
| 261 | listID: mailingList.id, | |
| 262 | listRID: mailingList.rid, | |
| 263 | listName: mailingList.name, | |
| 264 | listOwner: mailingList.owner, | |
| 265 | subject: thread.subject, | |
| 266 | latestSender: thread.sender, | |
| 267 | lastActivityAt: thread.updated, | |
| 268 | messageCount: thread.replies + 1, | |
| 269 | repo: Self.deriveRepositoryName(from: mailingList.name), | |
| 270 | containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"), | |
| 271 | isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated) | |
| 272 | ) | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] { | |
| 277 | var grouped: [String: InboxThreadSummary] = [:] | |
| 278 | ||
| 279 | for thread in threads { | |
| 280 | guard let existing = grouped[thread.threadGroupingKey] else { | |
| 281 | grouped[thread.threadGroupingKey] = thread | |
| 282 | continue | |
| 283 | } | |
| 284 | ||
| 285 | let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing | |
| 286 | let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted() | |
| 287 | let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted() | |
| 288 | let mergedMessageCount = max( | |
| 289 | existing.messageCount ?? existing.threadRootMessageIDs.count, | |
| 290 | thread.messageCount ?? thread.threadRootMessageIDs.count, | |
| 291 | mergedRootMessageIDs.count | |
| 292 | ) | |
| 293 | ||
| 294 | grouped[thread.threadGroupingKey] = InboxThreadSummary( | |
| 295 | rootEmailID: latest.rootEmailID, | |
| 296 | rootMessageID: latest.rootMessageID, | |
| 297 | threadRootEmailIDs: mergedRootEmailIDs, | |
| 298 | threadRootMessageIDs: mergedRootMessageIDs, | |
| 299 | listID: latest.listID, | |
| 300 | listRID: latest.listRID, | |
| 301 | listName: latest.listName, | |
| 302 | listOwner: latest.listOwner, | |
| 303 | subject: latest.subject, | |
| 304 | latestSender: latest.latestSender, | |
| 305 | lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt), | |
| 306 | messageCount: mergedMessageCount, | |
| 307 | repo: latest.repo ?? existing.repo, | |
| 308 | containsPatch: latest.containsPatch || existing.containsPatch, | |
| 309 | isUnread: latest.isUnread || existing.isUnread | |
| 310 | ) | |
| 311 | } | |
| 312 | ||
| 313 | return grouped.values.sorted { lhs, rhs in | |
| 314 | if lhs.lastActivityAt == rhs.lastActivityAt { | |
| 315 | return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending | |
| 316 | } | |
| 317 | return lhs.lastActivityAt > rhs.lastActivityAt | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] { | |
| 322 | var seen = Set<String>() | |
| 323 | return mailingLists.filter { mailingList in | |
| 324 | seen.insert(mailingList.rid).inserted | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | nonisolated static func deriveRepositoryName(from listName: String) -> String? { | |
| 329 | let separators = ["-devel", "-patches", "-dev", ".patches"] | |
| 330 | for separator in separators where listName.hasSuffix(separator) { | |
| 331 | return String(listName.dropLast(separator.count)) | |
| 332 | } | |
| 333 | return nil | |
| 334 | } | |
| 335 | } | |
Hutch/Views/Inbox/ThreadDetailView.swift added +324
| @@ -0,0 +1,324 @@ | ||
| 1 | import MessageUI | |
| 2 | import os | |
| 3 | import SwiftUI | |
| 4 | import UIKit | |
| 5 | ||
| 6 | private let inboxReplyLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxReply") | |
| 7 | ||
| 8 | struct ThreadDetailView: View { | |
| 9 | let thread: InboxThreadSummary | |
| 10 | let onViewed: () -> Void | |
| 11 | ||
| 12 | @Environment(AppState.self) private var appState | |
| 13 | @State private var viewModel: ThreadViewModel? | |
| 14 | @State private var replySuccessMessage: String? | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | Group { | |
| 18 | if let viewModel { | |
| 19 | content(viewModel) | |
| 20 | } else { | |
| 21 | SRHTLoadingStateView(message: "Loading thread…") | |
| 22 | } | |
| 23 | } | |
| 24 | .navigationTitle("Thread") | |
| 25 | .navigationBarTitleDisplayMode(.inline) | |
| 26 | .task { | |
| 27 | if viewModel == nil { | |
| 28 | onViewed() | |
| 29 | let vm = ThreadViewModel(summary: thread, client: appState.client) | |
| 30 | viewModel = vm | |
| 31 | await vm.loadThread() | |
| 32 | } | |
| 33 | } | |
| 34 | .sheet(item: Binding( | |
| 35 | get: { viewModel?.composeDraft }, | |
| 36 | set: { _ in viewModel?.dismissReply() } | |
| 37 | )) { draft in | |
| 38 | MailComposeView(draft: draft) { result in | |
| 39 | switch result { | |
| 40 | case .failed(let message): | |
| 41 | inboxReplyLogger.error("Inbox reply failed for thread \(thread.debugIdentifierSummary, privacy: .public): \(message, privacy: .public)") | |
| 42 | viewModel?.error = message | |
| 43 | case .cancelled: | |
| 44 | inboxReplyLogger.debug("Inbox reply cancelled for thread \(thread.debugIdentifierSummary, privacy: .public)") | |
| 45 | case .saved: | |
| 46 | inboxReplyLogger.debug("Inbox reply draft saved for thread \(thread.debugIdentifierSummary, privacy: .public)") | |
| 47 | case .sent: | |
| 48 | inboxReplyLogger.debug("Inbox reply handed off to Mail for thread \(thread.debugIdentifierSummary, privacy: .public)") | |
| 49 | replySuccessMessage = "Reply handed off to Mail." | |
| 50 | Task { | |
| 51 | await viewModel?.loadThread() | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | .overlay(alignment: .top) { | |
| 57 | if let replySuccessMessage { | |
| 58 | Text(replySuccessMessage) | |
| 59 | .font(.caption.weight(.medium)) | |
| 60 | .padding(.horizontal, 12) | |
| 61 | .padding(.vertical, 8) | |
| 62 | .background(.thinMaterial, in: Capsule()) | |
| 63 | .padding(.top, 8) | |
| 64 | .transition(.move(edge: .top).combined(with: .opacity)) | |
| 65 | } | |
| 66 | } | |
| 67 | .animation(.easeInOut(duration: 0.2), value: replySuccessMessage) | |
| 68 | .onChange(of: replySuccessMessage) { _, message in | |
| 69 | guard message != nil else { return } | |
| 70 | Task { @MainActor in | |
| 71 | try? await Task.sleep(for: .seconds(2)) | |
| 72 | if self.replySuccessMessage == message { | |
| 73 | self.replySuccessMessage = nil | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | @ViewBuilder | |
| 80 | private func content(_ viewModel: ThreadViewModel) -> some View { | |
| 81 | @Bindable var vm = viewModel | |
| 82 | ||
| 83 | List { | |
| 84 | if let thread = viewModel.thread { | |
| 85 | Section { | |
| 86 | VStack(alignment: .leading, spacing: 6) { | |
| 87 | Text(thread.displaySubject) | |
| 88 | .font(.headline) | |
| 89 | Text(headerMetadata(thread)) | |
| 90 | .font(.caption) | |
| 91 | .foregroundStyle(.secondary) | |
| 92 | } | |
| 93 | .padding(.vertical, 4) | |
| 94 | } | |
| 95 | ||
| 96 | ForEach(thread.messages) { message in | |
| 97 | InboxMessageRow(message: message) | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | .listStyle(.plain) | |
| 102 | .toolbar { | |
| 103 | ToolbarItem(placement: .topBarTrailing) { | |
| 104 | Button("Reply") { | |
| 105 | viewModel.prepareReply() | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | .overlay { | |
| 110 | if viewModel.isLoading, viewModel.thread == nil { | |
| 111 | SRHTLoadingStateView(message: "Loading thread…") | |
| 112 | } else if let error = viewModel.error, viewModel.thread == nil { | |
| 113 | SRHTErrorStateView( | |
| 114 | title: "Couldn't Load Thread", | |
| 115 | message: error, | |
| 116 | retryAction: { await viewModel.loadThread() } | |
| 117 | ) | |
| 118 | } | |
| 119 | } | |
| 120 | .srhtErrorBanner(error: $vm.error) | |
| 121 | .refreshable { | |
| 122 | await viewModel.loadThread() | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | private func headerMetadata(_ thread: InboxThreadDetail) -> String { | |
| 127 | var parts = [thread.listDisplayName] | |
| 128 | if let messageCount = thread.messageCount, messageCount > 1 { | |
| 129 | parts.append("\(messageCount) messages") | |
| 130 | } | |
| 131 | parts.append(thread.lastActivityAt.relativeDescription) | |
| 132 | return parts.joined(separator: " • ") | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | private struct InboxMessageRow: View { | |
| 137 | let message: InboxMessage | |
| 138 | ||
| 139 | var body: some View { | |
| 140 | VStack(alignment: .leading, spacing: 10) { | |
| 141 | HStack(alignment: .top, spacing: 12) { | |
| 142 | VStack(alignment: .leading, spacing: 2) { | |
| 143 | Text(senderLine) | |
| 144 | .font(.subheadline.weight(.medium)) | |
| 145 | .lineLimit(2) | |
| 146 | Text(message.date.formatted(date: .abbreviated, time: .shortened)) | |
| 147 | .font(.caption) | |
| 148 | .foregroundStyle(.secondary) | |
| 149 | } | |
| 150 | ||
| 151 | Spacer() | |
| 152 | ||
| 153 | if message.isPatch { | |
| 154 | Text("Patch") | |
| 155 | .font(.caption2.weight(.medium)) | |
| 156 | .foregroundStyle(.secondary) | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | ForEach(Array(message.contentBlocks.enumerated()), id: \.offset) { _, block in | |
| 161 | switch block { | |
| 162 | case .plainText(let text): | |
| 163 | Text(text) | |
| 164 | .font(.body) | |
| 165 | .textSelection(.enabled) | |
| 166 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 167 | .fixedSize(horizontal: false, vertical: true) | |
| 168 | case .diff(let diff): | |
| 169 | ScrollView(.horizontal) { | |
| 170 | DiffView(diff: diff) | |
| 171 | .textSelection(.enabled) | |
| 172 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 177 | .padding(.vertical, 6) | |
| 178 | .listRowSeparator(.visible) | |
| 179 | } | |
| 180 | ||
| 181 | private var senderLine: String { | |
| 182 | if let email = message.senderEmailAddress, | |
| 183 | email.caseInsensitiveCompare(message.senderDisplayName) != .orderedSame { | |
| 184 | return "\(message.senderDisplayName) <\(email)>" | |
| 185 | } | |
| 186 | return message.senderDisplayName | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | private struct MailComposeView: UIViewControllerRepresentable { | |
| 191 | let draft: MailComposeDraft | |
| 192 | let onComplete: (Result) -> Void | |
| 193 | ||
| 194 | enum Result { | |
| 195 | case cancelled | |
| 196 | case saved | |
| 197 | case sent | |
| 198 | case failed(String) | |
| 199 | } | |
| 200 | ||
| 201 | func makeCoordinator() -> Coordinator { | |
| 202 | Coordinator(onComplete: onComplete) | |
| 203 | } | |
| 204 | ||
| 205 | func makeUIViewController(context: Context) -> UIViewController { | |
| 206 | guard MFMailComposeViewController.canSendMail() else { | |
| 207 | let controller = UINavigationController(rootViewController: MailUnavailableViewController(onDismiss: { | |
| 208 | context.coordinator.onComplete(.failed("Mail is not configured on this device.")) | |
| 209 | })) | |
| 210 | DispatchQueue.main.async { | |
| 211 | UIImpactFeedbackGenerator(style: .light).impactOccurred() | |
| 212 | } | |
| 213 | return controller | |
| 214 | } | |
| 215 | ||
| 216 | let controller = MFMailComposeViewController() | |
| 217 | controller.mailComposeDelegate = context.coordinator | |
| 218 | controller.setToRecipients(draft.recipients) | |
| 219 | if !draft.ccRecipients.isEmpty { | |
| 220 | controller.setCcRecipients(draft.ccRecipients) | |
| 221 | } | |
| 222 | if !draft.subject.isEmpty { | |
| 223 | controller.setSubject(draft.subject) | |
| 224 | } | |
| 225 | if !draft.body.isEmpty { | |
| 226 | controller.setMessageBody(draft.body, isHTML: false) | |
| 227 | } | |
| 228 | return controller | |
| 229 | } | |
| 230 | ||
| 231 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} | |
| 232 | ||
| 233 | final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { | |
| 234 | let onComplete: (Result) -> Void | |
| 235 | ||
| 236 | init(onComplete: @escaping (Result) -> Void) { | |
| 237 | self.onComplete = onComplete | |
| 238 | } | |
| 239 | ||
| 240 | func mailComposeController( | |
| 241 | _ controller: MFMailComposeViewController, | |
| 242 | didFinishWith result: MFMailComposeResult, | |
| 243 | error: Error? | |
| 244 | ) { | |
| 245 | if error != nil { | |
| 246 | let message = error?.localizedDescription ?? "The reply could not be sent." | |
| 247 | presentFailureAlert(on: controller, message: message) | |
| 248 | onComplete(.failed(message)) | |
| 249 | return | |
| 250 | } | |
| 251 | switch result { | |
| 252 | case .cancelled: | |
| 253 | controller.dismiss(animated: true) | |
| 254 | onComplete(.cancelled) | |
| 255 | case .saved: | |
| 256 | controller.dismiss(animated: true) | |
| 257 | onComplete(.saved) | |
| 258 | case .sent: | |
| 259 | controller.dismiss(animated: true) | |
| 260 | onComplete(.sent) | |
| 261 | case .failed: | |
| 262 | let message = "Mail could not send the reply from the configured iOS Mail account." | |
| 263 | presentFailureAlert(on: controller, message: message) | |
| 264 | onComplete(.failed(message)) | |
| 265 | @unknown default: | |
| 266 | let message = "Mail returned an unknown result while sending the reply." | |
| 267 | presentFailureAlert(on: controller, message: message) | |
| 268 | onComplete(.failed(message)) | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | private func presentFailureAlert(on controller: UIViewController, message: String) { | |
| 273 | guard controller.presentedViewController == nil else { return } | |
| 274 | let alert = UIAlertController(title: "Reply Failed", message: message, preferredStyle: .alert) | |
| 275 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | |
| 276 | controller.present(alert, animated: true) | |
| 277 | } | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | private final class MailUnavailableViewController: UIViewController { | |
| 282 | private let onDismiss: () -> Void | |
| 283 | ||
| 284 | init(onDismiss: @escaping () -> Void) { | |
| 285 | self.onDismiss = onDismiss | |
| 286 | super.init(nibName: nil, bundle: nil) | |
| 287 | } | |
| 288 | ||
| 289 | @available(*, unavailable) | |
| 290 | required init?(coder: NSCoder) { | |
| 291 | fatalError("init(coder:) has not been implemented") | |
| 292 | } | |
| 293 | ||
| 294 | override func viewDidLoad() { | |
| 295 | super.viewDidLoad() | |
| 296 | view.backgroundColor = .systemBackground | |
| 297 | navigationItem.title = "Reply" | |
| 298 | navigationItem.rightBarButtonItem = UIBarButtonItem( | |
| 299 | barButtonSystemItem: .done, | |
| 300 | target: self, | |
| 301 | action: #selector(dismissSelf) | |
| 302 | ) | |
| 303 | ||
| 304 | let label = UILabel() | |
| 305 | label.translatesAutoresizingMaskIntoConstraints = false | |
| 306 | label.text = "Mail is not configured on this device." | |
| 307 | label.textAlignment = .center | |
| 308 | label.numberOfLines = 0 | |
| 309 | label.textColor = .secondaryLabel | |
| 310 | ||
| 311 | view.addSubview(label) | |
| 312 | NSLayoutConstraint.activate([ | |
| 313 | label.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), | |
| 314 | label.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), | |
| 315 | label.centerYAnchor.constraint(equalTo: view.centerYAnchor) | |
| 316 | ]) | |
| 317 | } | |
| 318 | ||
| 319 | @objc | |
| 320 | private func dismissSelf() { | |
| 321 | dismiss(animated: true) | |
| 322 | onDismiss() | |
| 323 | } | |
| 324 | } | |
Hutch/Views/Inbox/ThreadViewModel.swift added +702
| @@ -0,0 +1,702 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox") | |
| 5 | ||
| 6 | private struct InboxThreadDetailResponse: Decodable, Sendable { | |
| 7 | let list: InboxThreadDetailList? | |
| 8 | } | |
| 9 | ||
| 10 | private struct InboxThreadDetailList: Decodable, Sendable { | |
| 11 | let threads: InboxThreadPayloadPage? | |
| 12 | } | |
| 13 | ||
| 14 | private struct InboxThreadLookupResponse: Decodable, Sendable { | |
| 15 | let list: InboxThreadLookupList? | |
| 16 | } | |
| 17 | ||
| 18 | private struct InboxThreadLookupList: Decodable, Sendable { | |
| 19 | let message: InboxThreadLookupMessage? | |
| 20 | } | |
| 21 | ||
| 22 | private struct InboxThreadLookupMessage: Decodable, Sendable { | |
| 23 | let thread: InboxThreadPayloadDetail? | |
| 24 | } | |
| 25 | ||
| 26 | private struct InboxThreadPayloadDetail: Decodable, Sendable { | |
| 27 | let subject: String? | |
| 28 | let updated: Date? | |
| 29 | let replies: Int? | |
| 30 | let sender: Entity? | |
| 31 | let list: InboxMailingListReference? | |
| 32 | let root: InboxThreadMessagePayload? | |
| 33 | let descendants: InboxThreadMessagesPage? | |
| 34 | } | |
| 35 | ||
| 36 | private struct InboxThreadPayloadPage: Decodable, Sendable { | |
| 37 | let results: [InboxThreadPayloadDetail] | |
| 38 | let cursor: String? | |
| 39 | } | |
| 40 | ||
| 41 | private struct InboxThreadMessagesPage: Decodable, Sendable { | |
| 42 | let results: [InboxThreadMessagePayload]? | |
| 43 | let cursor: String? | |
| 44 | } | |
| 45 | ||
| 46 | private struct InboxThreadMessagePayload: Decodable, Sendable { | |
| 47 | let id: Int? | |
| 48 | let sender: Entity? | |
| 49 | let received: Date? | |
| 50 | let date: Date? | |
| 51 | let subject: String? | |
| 52 | let messageID: String? | |
| 53 | let body: String? | |
| 54 | let rawMessage: URL? | |
| 55 | let patch: InboxPatchPreview? | |
| 56 | } | |
| 57 | ||
| 58 | @Observable | |
| 59 | @MainActor | |
| 60 | final class ThreadViewModel { | |
| 61 | private(set) var thread: InboxThreadDetail? | |
| 62 | private(set) var isLoading = false | |
| 63 | var error: String? | |
| 64 | var composeDraft: MailComposeDraft? | |
| 65 | ||
| 66 | private let summary: InboxThreadSummary | |
| 67 | private let client: SRHTClient | |
| 68 | ||
| 69 | private static let threadDetailQuery = """ | |
| 70 | query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) { | |
| 71 | list(rid: $rid) { | |
| 72 | threads(cursor: $cursor) { | |
| 73 | results { | |
| 74 | subject | |
| 75 | updated | |
| 76 | replies | |
| 77 | sender { canonicalName } | |
| 78 | list { | |
| 79 | id | |
| 80 | rid | |
| 81 | name | |
| 82 | owner { canonicalName } | |
| 83 | } | |
| 84 | root { | |
| 85 | id | |
| 86 | sender { canonicalName } | |
| 87 | received | |
| 88 | date | |
| 89 | subject | |
| 90 | messageID | |
| 91 | body | |
| 92 | rawMessage | |
| 93 | patch { subject } | |
| 94 | } | |
| 95 | descendants(cursor: $descCursor) { | |
| 96 | results { | |
| 97 | id | |
| 98 | sender { canonicalName } | |
| 99 | received | |
| 100 | date | |
| 101 | subject | |
| 102 | messageID | |
| 103 | body | |
| 104 | rawMessage | |
| 105 | patch { subject } | |
| 106 | } | |
| 107 | cursor | |
| 108 | } | |
| 109 | } | |
| 110 | cursor | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | """ | |
| 115 | ||
| 116 | private static let threadByMessageIDQuery = """ | |
| 117 | query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) { | |
| 118 | list(rid: $rid) { | |
| 119 | message(messageID: $messageID) { | |
| 120 | thread { | |
| 121 | subject | |
| 122 | updated | |
| 123 | replies | |
| 124 | sender { canonicalName } | |
| 125 | list { | |
| 126 | id | |
| 127 | rid | |
| 128 | name | |
| 129 | owner { canonicalName } | |
| 130 | } | |
| 131 | root { | |
| 132 | id | |
| 133 | sender { canonicalName } | |
| 134 | received | |
| 135 | date | |
| 136 | subject | |
| 137 | messageID | |
| 138 | body | |
| 139 | rawMessage | |
| 140 | patch { subject } | |
| 141 | } | |
| 142 | descendants(cursor: $descCursor) { | |
| 143 | results { | |
| 144 | id | |
| 145 | sender { canonicalName } | |
| 146 | received | |
| 147 | date | |
| 148 | subject | |
| 149 | messageID | |
| 150 | body | |
| 151 | rawMessage | |
| 152 | patch { subject } | |
| 153 | } | |
| 154 | cursor | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | """ | |
| 161 | ||
| 162 | init(summary: InboxThreadSummary, client: SRHTClient) { | |
| 163 | self.summary = summary | |
| 164 | self.client = client | |
| 165 | } | |
| 166 | ||
| 167 | func loadThread() async { | |
| 168 | guard !isLoading else { return } | |
| 169 | isLoading = true | |
| 170 | error = nil | |
| 171 | defer { isLoading = false } | |
| 172 | ||
| 173 | inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)") | |
| 174 | ||
| 175 | do { | |
| 176 | let threadPayloads = try await fetchThreadPayloads() | |
| 177 | ||
| 178 | guard !threadPayloads.isEmpty else { | |
| 179 | throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) | |
| 180 | } | |
| 181 | ||
| 182 | let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference( | |
| 183 | id: summary.listID, | |
| 184 | rid: summary.listRID, | |
| 185 | name: summary.listName, | |
| 186 | owner: summary.listOwner | |
| 187 | ) | |
| 188 | var messagesByID: [Int: InboxMessage] = [:] | |
| 189 | ||
| 190 | for payload in threadPayloads { | |
| 191 | guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else { | |
| 192 | continue | |
| 193 | } | |
| 194 | messagesByID[rootMessage.id] = rootMessage | |
| 195 | ||
| 196 | let descendantMessages = try await fetchAllDescendantMessages( | |
| 197 | initialPayload: payload, | |
| 198 | candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID) | |
| 199 | ) | |
| 200 | for message in descendantMessages { | |
| 201 | messagesByID[message.id] = message | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | let messages = messagesByID.values.sorted { $0.date < $1.date } | |
| 206 | guard !messages.isEmpty else { | |
| 207 | throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)]) | |
| 208 | } | |
| 209 | ||
| 210 | let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0] | |
| 211 | thread = InboxThreadDetail( | |
| 212 | id: summary.id, | |
| 213 | rootEmailID: summary.rootEmailID, | |
| 214 | rootMessageID: summary.rootMessageID, | |
| 215 | subject: latestPayload.subject ?? summary.subject, | |
| 216 | author: latestPayload.sender ?? summary.latestSender, | |
| 217 | lastActivityAt: latestPayload.updated ?? summary.lastActivityAt, | |
| 218 | mailto: nil, | |
| 219 | listID: listReference.id, | |
| 220 | listRID: listReference.rid, | |
| 221 | listName: listReference.name, | |
| 222 | listOwner: listReference.owner, | |
| 223 | messageCount: max(messages.count, summary.messageCount ?? 0), | |
| 224 | messages: messages | |
| 225 | ) | |
| 226 | } catch { | |
| 227 | thread = nil | |
| 228 | self.error = error.localizedDescription | |
| 229 | inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)") | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] { | |
| 234 | var payloads: [InboxThreadPayloadDetail] = [] | |
| 235 | var seenRoots = Set<String>() | |
| 236 | ||
| 237 | for rootMessageID in summary.threadRootMessageIDs { | |
| 238 | guard !seenRoots.contains(rootMessageID) else { continue } | |
| 239 | seenRoots.insert(rootMessageID) | |
| 240 | if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) { | |
| 241 | payloads.append(payload) | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) { | |
| 246 | payloads.append(fallback) | |
| 247 | } | |
| 248 | ||
| 249 | return payloads | |
| 250 | } | |
| 251 | ||
| 252 | private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { | |
| 253 | if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) { | |
| 254 | return messageMatchedThread | |
| 255 | } | |
| 256 | return try await scanThreadPages(targetRootMessageID: rootMessageID) | |
| 257 | } | |
| 258 | ||
| 259 | private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? { | |
| 260 | let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID) | |
| 261 | inboxLogger.debug( | |
| 262 | "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)" | |
| 263 | ) | |
| 264 | ||
| 265 | var lastLookupError: Error? | |
| 266 | ||
| 267 | for messageID in candidateMessageIDs { | |
| 268 | inboxLogger.debug( | |
| 269 | "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)" | |
| 270 | ) | |
| 271 | ||
| 272 | do { | |
| 273 | let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( | |
| 274 | client: client, | |
| 275 | query: Self.threadByMessageIDQuery, | |
| 276 | variables: [ | |
| 277 | "rid": self.summary.listRID, | |
| 278 | "messageID": messageID, | |
| 279 | "descCursor": nil as String? | |
| 280 | ] | |
| 281 | ) | |
| 282 | ||
| 283 | if let thread = response.list?.message?.thread { | |
| 284 | return thread | |
| 285 | } | |
| 286 | } catch let error as SRHTError { | |
| 287 | switch error { | |
| 288 | case .graphQLErrors(let errors): | |
| 289 | let combinedMessage = errors.map(\.message).joined(separator: " | ") | |
| 290 | inboxLogger.error( | |
| 291 | "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)" | |
| 292 | ) | |
| 293 | if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) { | |
| 294 | lastLookupError = error | |
| 295 | continue | |
| 296 | } | |
| 297 | throw error | |
| 298 | default: | |
| 299 | throw error | |
| 300 | } | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | if let lastLookupError { | |
| 305 | inboxLogger.debug( | |
| 306 | "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)" | |
| 307 | ) | |
| 308 | } | |
| 309 | return nil | |
| 310 | } | |
| 311 | ||
| 312 | private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? { | |
| 313 | var threadCursor: String? | |
| 314 | ||
| 315 | while true { | |
| 316 | var variables: [String: any Sendable] = ["rid": summary.listRID] | |
| 317 | if let threadCursor { | |
| 318 | variables["cursor"] = threadCursor | |
| 319 | } | |
| 320 | ||
| 321 | let response: InboxThreadDetailResponse = try await Self.executeGraphQLRequest( | |
| 322 | client: client, | |
| 323 | query: Self.threadDetailQuery, | |
| 324 | variables: { | |
| 325 | var variables = variables | |
| 326 | variables["descCursor"] = nil as String? | |
| 327 | return variables | |
| 328 | }() | |
| 329 | ) | |
| 330 | ||
| 331 | guard let threadPage = response.list?.threads else { | |
| 332 | throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)]) | |
| 333 | } | |
| 334 | ||
| 335 | let candidates = threadPage.results.map { payload in | |
| 336 | "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")" | |
| 337 | }.joined(separator: " | ") | |
| 338 | inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)") | |
| 339 | ||
| 340 | if let matchedThread = threadPage.results.first(where: { | |
| 341 | $0.root?.messageID == targetRootMessageID || | |
| 342 | $0.root?.id == summary.rootEmailID || | |
| 343 | $0.root?.subject == summary.subject | |
| 344 | }) { | |
| 345 | return matchedThread | |
| 346 | } | |
| 347 | ||
| 348 | guard let nextCursor = threadPage.cursor else { | |
| 349 | return nil | |
| 350 | } | |
| 351 | threadCursor = nextCursor | |
| 352 | } | |
| 353 | } | |
| 354 | ||
| 355 | private func fetchAllDescendantMessages( | |
| 356 | initialPayload: InboxThreadPayloadDetail, | |
| 357 | candidateMessageIDs: [String] | |
| 358 | ) async throws -> [InboxMessage] { | |
| 359 | var messagesByID: [Int: InboxMessage] = [:] | |
| 360 | ||
| 361 | for payload in initialPayload.descendants?.results ?? [] { | |
| 362 | if let message = Self.message(from: payload, fallbackID: nil) { | |
| 363 | messagesByID[message.id] = message | |
| 364 | } | |
| 365 | } | |
| 366 | ||
| 367 | var descendantCursor = initialPayload.descendants?.cursor | |
| 368 | while let currentCursor = descendantCursor { | |
| 369 | guard let page = try await fetchDescendantPage( | |
| 370 | cursor: currentCursor, | |
| 371 | candidateMessageIDs: candidateMessageIDs | |
| 372 | ) else { | |
| 373 | break | |
| 374 | } | |
| 375 | ||
| 376 | for payload in page.results ?? [] { | |
| 377 | if let message = Self.message(from: payload, fallbackID: nil) { | |
| 378 | messagesByID[message.id] = message | |
| 379 | } | |
| 380 | } | |
| 381 | descendantCursor = page.cursor | |
| 382 | } | |
| 383 | ||
| 384 | return messagesByID.values.sorted { $0.date < $1.date } | |
| 385 | } | |
| 386 | ||
| 387 | private func fetchDescendantPage( | |
| 388 | cursor: String, | |
| 389 | candidateMessageIDs: [String] | |
| 390 | ) async throws -> InboxThreadMessagesPage? { | |
| 391 | for messageID in candidateMessageIDs { | |
| 392 | let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest( | |
| 393 | client: client, | |
| 394 | query: Self.threadByMessageIDQuery, | |
| 395 | variables: [ | |
| 396 | "rid": summary.listRID, | |
| 397 | "messageID": messageID, | |
| 398 | "descCursor": cursor | |
| 399 | ] | |
| 400 | ) | |
| 401 | ||
| 402 | if let descendants = response.list?.message?.thread?.descendants { | |
| 403 | return descendants | |
| 404 | } | |
| 405 | } | |
| 406 | ||
| 407 | return nil | |
| 408 | } | |
| 409 | ||
| 410 | func prepareReply() { | |
| 411 | guard let thread else { | |
| 412 | error = "This thread is not ready to reply to yet." | |
| 413 | return | |
| 414 | } | |
| 415 | inboxLogger.debug( | |
| 416 | "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" | |
| 417 | ) | |
| 418 | composeDraft = MailComposeDraft( | |
| 419 | recipients: [thread.replyRecipient], | |
| 420 | ccRecipients: [], | |
| 421 | subject: thread.replySubject, | |
| 422 | body: "" | |
| 423 | ) | |
| 424 | } | |
| 425 | ||
| 426 | func dismissReply() { | |
| 427 | composeDraft = nil | |
| 428 | } | |
| 429 | ||
| 430 | private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? { | |
| 431 | guard let payload else { return nil } | |
| 432 | guard let id = payload.id ?? fallbackID, | |
| 433 | let author = payload.sender, | |
| 434 | let date = payload.date ?? payload.received, | |
| 435 | let subject = payload.subject, | |
| 436 | let body = payload.body else { | |
| 437 | return nil | |
| 438 | } | |
| 439 | ||
| 440 | let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author) | |
| 441 | let displayBody = sanitizedDisplayBody(from: body) | |
| 442 | let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil) | |
| 443 | ||
| 444 | return InboxMessage( | |
| 445 | id: id, | |
| 446 | author: author, | |
| 447 | date: date, | |
| 448 | subject: subject, | |
| 449 | body: body, | |
| 450 | senderDisplayName: normalizedIdentity.displayName, | |
| 451 | senderEmailAddress: normalizedIdentity.emailAddress, | |
| 452 | isPatch: payload.patch != nil, | |
| 453 | contentBlocks: contentBlocks, | |
| 454 | rawMessageURL: payload.rawMessage | |
| 455 | ) | |
| 456 | } | |
| 457 | ||
| 458 | nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? { | |
| 459 | guard let components = URLComponents(string: mailto), | |
| 460 | components.scheme?.lowercased() == "mailto" else { | |
| 461 | return nil | |
| 462 | } | |
| 463 | ||
| 464 | let recipients = components.path | |
| 465 | .split(separator: ",") | |
| 466 | .map { String($0) } | |
| 467 | .filter { !$0.isEmpty } | |
| 468 | let queryItems = components.queryItems ?? [] | |
| 469 | let ccRecipients = queryItems | |
| 470 | .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })? | |
| 471 | .value? | |
| 472 | .split(separator: ",") | |
| 473 | .map(String.init) ?? [] | |
| 474 | let subject = queryItems | |
| 475 | .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })? | |
| 476 | .value ?? "" | |
| 477 | let body = queryItems | |
| 478 | .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })? | |
| 479 | .value ?? "" | |
| 480 | ||
| 481 | return MailComposeDraft( | |
| 482 | recipients: recipients, | |
| 483 | ccRecipients: ccRecipients, | |
| 484 | subject: subject, | |
| 485 | body: body | |
| 486 | ) | |
| 487 | } | |
| 488 | ||
| 489 | private static func messageIDCandidates(from messageID: String) -> [String] { | |
| 490 | let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 491 | guard !trimmedMessageID.isEmpty else { return [] } | |
| 492 | ||
| 493 | if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") { | |
| 494 | return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())] | |
| 495 | } | |
| 496 | ||
| 497 | return [trimmedMessageID, "<\(trimmedMessageID)>"] | |
| 498 | } | |
| 499 | ||
| 500 | private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) { | |
| 501 | guard let fromLine = leadingHeaderValue(named: "From", in: body) else { | |
| 502 | return fallbackSenderIdentity(from: fallbackAuthor) | |
| 503 | } | |
| 504 | ||
| 505 | let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 506 | if let start = trimmedFromLine.lastIndex(of: "<"), | |
| 507 | let end = trimmedFromLine.lastIndex(of: ">"), | |
| 508 | start < end { | |
| 509 | let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces) | |
| 510 | let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 511 | if !name.isEmpty { | |
| 512 | return (name, email.isEmpty ? nil : email) | |
| 513 | } | |
| 514 | return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email) | |
| 515 | } | |
| 516 | ||
| 517 | if trimmedFromLine.contains("@") { | |
| 518 | return (trimmedFromLine, trimmedFromLine) | |
| 519 | } | |
| 520 | ||
| 521 | return (trimmedFromLine, nil) | |
| 522 | } | |
| 523 | ||
| 524 | private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) { | |
| 525 | let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 526 | if canonicalName.contains("@") { | |
| 527 | return (canonicalName, canonicalName) | |
| 528 | } | |
| 529 | if canonicalName.hasPrefix("~") { | |
| 530 | return (String(canonicalName.dropFirst()), nil) | |
| 531 | } | |
| 532 | return (canonicalName, nil) | |
| 533 | } | |
| 534 | ||
| 535 | private static func sanitizedDisplayBody(from body: String) -> String { | |
| 536 | let lines = body.components(separatedBy: .newlines) | |
| 537 | let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"] | |
| 538 | var headerCount = 0 | |
| 539 | var blankLineIndex: Int? | |
| 540 | ||
| 541 | for (index, line) in lines.prefix(12).enumerated() { | |
| 542 | if line.isEmpty { | |
| 543 | blankLineIndex = index | |
| 544 | break | |
| 545 | } | |
| 546 | if headerPrefixes.contains(where: { line.hasPrefix($0) }) { | |
| 547 | headerCount += 1 | |
| 548 | } else if headerCount > 0 { | |
| 549 | break | |
| 550 | } | |
| 551 | } | |
| 552 | ||
| 553 | guard headerCount >= 2, let blankLineIndex else { | |
| 554 | return body | |
| 555 | } | |
| 556 | ||
| 557 | return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n") | |
| 558 | } | |
| 559 | ||
| 560 | nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { | |
| 561 | segmentMessageBody(body, isPatch: isPatch) | |
| 562 | } | |
| 563 | ||
| 564 | private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] { | |
| 565 | guard isPatch else { | |
| 566 | let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 567 | return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] | |
| 568 | } | |
| 569 | ||
| 570 | let normalizedBody = normalizeLineEndings(in: body) | |
| 571 | let lines = normalizedBody.components(separatedBy: "\n") | |
| 572 | guard let diffStartIndex = actualDiffStartIndex(in: lines) else { | |
| 573 | let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 574 | return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)] | |
| 575 | } | |
| 576 | ||
| 577 | var blocks: [InboxMessageContentBlock] = [] | |
| 578 | let leadingPlainText = lines[..<diffStartIndex] | |
| 579 | .joined(separator: "\n") | |
| 580 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 581 | if !leadingPlainText.isEmpty { | |
| 582 | blocks.append(.plainText(leadingPlainText)) | |
| 583 | } | |
| 584 | ||
| 585 | let remainingLines = Array(lines[diffStartIndex...]) | |
| 586 | let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator) | |
| 587 | ||
| 588 | let diffLines: ArraySlice<String> | |
| 589 | let trailingPlainText: String | |
| 590 | if let signatureIndex { | |
| 591 | diffLines = remainingLines[..<signatureIndex] | |
| 592 | trailingPlainText = remainingLines[signatureIndex...] | |
| 593 | .joined(separator: "\n") | |
| 594 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 595 | } else { | |
| 596 | diffLines = remainingLines[...] | |
| 597 | trailingPlainText = "" | |
| 598 | } | |
| 599 | ||
| 600 | let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) | |
| 601 | if !diff.isEmpty { | |
| 602 | blocks.append(.diff(diff)) | |
| 603 | } | |
| 604 | ||
| 605 | if !trailingPlainText.isEmpty { | |
| 606 | blocks.append(.plainText(trailingPlainText)) | |
| 607 | } | |
| 608 | return blocks | |
| 609 | } | |
| 610 | ||
| 611 | private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? { | |
| 612 | if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) { | |
| 613 | return explicitDiffIndex | |
| 614 | } | |
| 615 | ||
| 616 | for index in lines.indices { | |
| 617 | let line = lines[index] | |
| 618 | guard line.hasPrefix("--- ") else { continue } | |
| 619 | let nextIndex = lines.index(after: index) | |
| 620 | guard nextIndex < lines.endIndex else { continue } | |
| 621 | let nextLine = lines[nextIndex] | |
| 622 | guard nextLine.hasPrefix("+++ ") else { continue } | |
| 623 | ||
| 624 | let oldPath = String(line.dropFirst(4)) | |
| 625 | let newPath = String(nextLine.dropFirst(4)) | |
| 626 | let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") && | |
| 627 | (newPath.hasPrefix("b/") || newPath == "/dev/null") | |
| 628 | ||
| 629 | if looksLikeUnifiedDiff { | |
| 630 | return index | |
| 631 | } | |
| 632 | } | |
| 633 | ||
| 634 | return nil | |
| 635 | } | |
| 636 | ||
| 637 | private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool { | |
| 638 | line == "-- " || line == "--" | |
| 639 | } | |
| 640 | ||
| 641 | private nonisolated static func normalizeLineEndings(in text: String) -> String { | |
| 642 | text | |
| 643 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 644 | .replacingOccurrences(of: "\r", with: "\n") | |
| 645 | } | |
| 646 | ||
| 647 | private static func leadingHeaderValue(named headerName: String, in body: String) -> String? { | |
| 648 | let prefix = "\(headerName):" | |
| 649 | let lines = body.components(separatedBy: .newlines) | |
| 650 | for line in lines.prefix(12) { | |
| 651 | if line.isEmpty { | |
| 652 | break | |
| 653 | } | |
| 654 | if line.hasPrefix(prefix) { | |
| 655 | return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) | |
| 656 | } | |
| 657 | } | |
| 658 | return nil | |
| 659 | } | |
| 660 | ||
| 661 | private static func executeGraphQLRequest<T: Decodable>( | |
| 662 | client: SRHTClient, | |
| 663 | query: String, | |
| 664 | variables: [String: any Sendable] | |
| 665 | ) async throws -> T { | |
| 666 | guard let token = KeychainHelper.loadToken(), !token.isEmpty else { | |
| 667 | throw SRHTError.unauthorized | |
| 668 | } | |
| 669 | ||
| 670 | var request = URLRequest(url: SRHTService.lists.url) | |
| 671 | request.httpMethod = "POST" | |
| 672 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 673 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 674 | ||
| 675 | let encoder = JSONEncoder() | |
| 676 | request.httpBody = try encoder.encode( | |
| 677 | GraphQLRequestBody( | |
| 678 | query: query, | |
| 679 | variables: variables.mapValues { AnyCodable($0) } | |
| 680 | ) | |
| 681 | ) | |
| 682 | ||
| 683 | let (data, _) = try await URLSession.shared.data(for: request) | |
| 684 | #if DEBUG | |
| 685 | let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" | |
| 686 | inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)") | |
| 687 | #endif | |
| 688 | ||
| 689 | let decoder = JSONDecoder() | |
| 690 | decoder.dateDecodingStrategy = .srhtFlexible | |
| 691 | let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data) | |
| 692 | if let errors = envelope.errors, !errors.isEmpty { | |
| 693 | throw SRHTError.graphQLErrors(errors) | |
| 694 | } | |
| 695 | guard let payload = envelope.data else { | |
| 696 | throw SRHTError.decodingError( | |
| 697 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response")) | |
| 698 | ) | |
| 699 | } | |
| 700 | return payload | |
| 701 | } | |
| 702 | } | |
Hutch/Views/Repositories/DiffView.swift +15 −7
| @@ -9,14 +9,23 @@ struct DiffView: View { | ||
| 9 | 9 | let diff: String |
| 10 | 10 | |
| 11 | 11 | var body: some View { |
| 12 | let lines = diff.components(separatedBy: "\n") | |
| 12 | let lines = normalizedDiff.components(separatedBy: "\n") | |
| 13 | 13 | |
| 14 | LazyVStack(alignment: .leading, spacing: 0) { | |
| 14 | VStack(alignment: .leading, spacing: 0) { | |
| 15 | 15 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in |
| 16 | 16 | DiffLineView(line: line) |
| 17 | 17 | } |
| 18 | 18 | } |
| 19 | .font(.caption.monospaced()) | |
| 19 | .font(.system(.caption, design: .monospaced)) | |
| 20 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 21 | .background(Color(.secondarySystemBackground)) | |
| 22 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) | |
| 23 | } | |
| 24 | ||
| 25 | private var normalizedDiff: String { | |
| 26 | diff | |
| 27 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 28 | .replacingOccurrences(of: "\r", with: "\n") | |
| 20 | 29 | } |
| 21 | 30 | } |
| 22 | 31 | |
| @@ -27,7 +36,6 @@ private struct DiffLineView: View { | ||
| 27 | 36 | Text(line.isEmpty ? " " : line) |
| 28 | 37 | .frame(maxWidth: .infinity, alignment: .leading) |
| 29 | 38 | .padding(.horizontal, 8) |
| 30 | .padding(.vertical, 1) | |
| 31 | 39 | .background(backgroundColor) |
| 32 | 40 | .foregroundStyle(foregroundColor) |
| 33 | 41 | .fontWeight(isHeader ? .semibold : .regular) |
| @@ -47,9 +55,9 @@ private struct DiffLineView: View { | ||
| 47 | 55 | switch kind { |
| 48 | 56 | case .added: .green.opacity(0.15) |
| 49 | 57 | case .removed: .red.opacity(0.15) |
| 50 | case .hunk: .gray.opacity(0.12) | |
| 51 | case .fileHeader: .gray.opacity(0.08) | |
| 52 | case .meta: .gray.opacity(0.05) | |
| 58 | case .hunk: .clear | |
| 59 | case .fileHeader: .clear | |
| 60 | case .meta: .clear | |
| 53 | 61 | case .context: .clear |
| 54 | 62 | } |
| 55 | 63 | } |
HutchTests/InboxViewModelTests.swift added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct InboxViewModelTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | func derivesRepositoryNameFromCommonPatchListSuffixes() { | |
| 9 | #expect(InboxViewModel.deriveRepositoryName(from: "hut-devel") == "hut") | |
| 10 | #expect(InboxViewModel.deriveRepositoryName(from: "git.patches") == "git") | |
| 11 | #expect(InboxViewModel.deriveRepositoryName(from: "discuss") == nil) | |
| 12 | } | |
| 13 | ||
| 14 | @Test | |
| 15 | func parsesMailtoDraft() { | |
| 16 | let draft = ThreadViewModel.mailComposeDraft( | |
| 17 | from: "mailto:list@example.com?cc=author@example.com&subject=Re:%20PATCH&body=LGTM" | |
| 18 | ) | |
| 19 | ||
| 20 | #expect(draft?.recipients == ["list@example.com"]) | |
| 21 | #expect(draft?.ccRecipients == ["author@example.com"]) | |
| 22 | #expect(draft?.subject == "Re: PATCH") | |
| 23 | #expect(draft?.body == "LGTM") | |
| 24 | } | |
| 25 | ||
| 26 | @Test | |
| 27 | func computesLocalUnreadStateFromLastViewedMarker() { | |
| 28 | let suiteName = "InboxViewModelTests-\(UUID().uuidString)" | |
| 29 | let defaults = UserDefaults(suiteName: suiteName)! | |
| 30 | defer { defaults.removePersistentDomain(forName: suiteName) } | |
| 31 | ||
| 32 | let threadID = "list#message" | |
| 33 | let lastActivity = Date(timeIntervalSince1970: 2_000) | |
| 34 | ||
| 35 | #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) | |
| 36 | ||
| 37 | InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 1_000), for: threadID, defaults: defaults) | |
| 38 | #expect(InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) | |
| 39 | ||
| 40 | InboxReadStateStore.markViewed(Date(timeIntervalSince1970: 2_500), for: threadID, defaults: defaults) | |
| 41 | #expect(!InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivity, defaults: defaults)) | |
| 42 | } | |
| 43 | ||
| 44 | @Test | |
| 45 | func normalizesThreadSubjectsForDisplay() { | |
| 46 | let summary = InboxThreadSummary( | |
| 47 | rootEmailID: 1, | |
| 48 | rootMessageID: "message", | |
| 49 | threadRootEmailIDs: [1], | |
| 50 | threadRootMessageIDs: ["message"], | |
| 51 | listID: 2, | |
| 52 | listRID: "list", | |
| 53 | listName: "hut-devel", | |
| 54 | listOwner: Entity(canonicalName: "~owner"), | |
| 55 | subject: "Re: Fwd: [PATCH] test: add parser ", | |
| 56 | latestSender: Entity(canonicalName: "~sender"), | |
| 57 | lastActivityAt: Date(timeIntervalSince1970: 2_000), | |
| 58 | messageCount: 3, | |
| 59 | repo: "hut", | |
| 60 | containsPatch: true, | |
| 61 | isUnread: true | |
| 62 | ) | |
| 63 | ||
| 64 | #expect(summary.displaySubject == "[PATCH] test: add parser") | |
| 65 | #expect(summary.metadataLine.contains("~sender")) | |
| 66 | #expect(summary.metadataLine.contains("2 replies")) | |
| 67 | } | |
| 68 | ||
| 69 | @Test | |
| 70 | func keepsDistinctThreadsDistinctByRootMessageID() { | |
| 71 | let baseList = InboxMailingListReference( | |
| 72 | id: 1, | |
| 73 | rid: "list", | |
| 74 | name: "hut-devel", | |
| 75 | owner: Entity(canonicalName: "~owner") | |
| 76 | ) | |
| 77 | ||
| 78 | let first = InboxThreadSummary( | |
| 79 | rootEmailID: 10, | |
| 80 | rootMessageID: "message-1", | |
| 81 | threadRootEmailIDs: [10], | |
| 82 | threadRootMessageIDs: ["message-1"], | |
| 83 | listID: baseList.id, | |
| 84 | listRID: baseList.rid, | |
| 85 | listName: baseList.name, | |
| 86 | listOwner: baseList.owner, | |
| 87 | subject: "[PATCH] test", | |
| 88 | latestSender: Entity(canonicalName: "~a"), | |
| 89 | lastActivityAt: Date(timeIntervalSince1970: 100), | |
| 90 | messageCount: 1, | |
| 91 | repo: "hut", | |
| 92 | containsPatch: true, | |
| 93 | isUnread: true | |
| 94 | ) | |
| 95 | let second = InboxThreadSummary( | |
| 96 | rootEmailID: 11, | |
| 97 | rootMessageID: "message-2", | |
| 98 | threadRootEmailIDs: [11], | |
| 99 | threadRootMessageIDs: ["message-2"], | |
| 100 | listID: baseList.id, | |
| 101 | listRID: baseList.rid, | |
| 102 | listName: baseList.name, | |
| 103 | listOwner: baseList.owner, | |
| 104 | subject: "[PATCH] test", | |
| 105 | latestSender: Entity(canonicalName: "~b"), | |
| 106 | lastActivityAt: Date(timeIntervalSince1970: 200), | |
| 107 | messageCount: 2, | |
| 108 | repo: "hut", | |
| 109 | containsPatch: true, | |
| 110 | isUnread: true | |
| 111 | ) | |
| 112 | ||
| 113 | #expect(first.id != second.id) | |
| 114 | } | |
| 115 | ||
| 116 | @Test | |
| 117 | func segmentsPatchBodyAndTreatsSignatureAsPlainText() { | |
| 118 | let body = """ | |
| 119 | From: Christian Cleberg <hello@cleberg.net> | |
| 120 | ||
| 121 | --- | |
| 122 | test-patch.txt | 1 + | |
| 123 | 1 file changed, 1 insertion(+) | |
| 124 | create mode 100644 test-patch.txt | |
| 125 | ||
| 126 | diff --git a/test-patch.txt b/test-patch.txt | |
| 127 | new file mode 100644 | |
| 128 | index 0000000..c7b6eed | |
| 129 | --- /dev/null | |
| 130 | +++ b/test-patch.txt | |
| 131 | @@ -0,0 +1 @@ | |
| 132 | +test Wed Mar 18 23:19:03 CDT 2026 | |
| 133 | -- | |
| 134 | 2.50.1 (Apple Git-155) | |
| 135 | """ | |
| 136 | ||
| 137 | let segments = ThreadViewModel.segmentMessageBodyForTesting(body, isPatch: true) | |
| 138 | ||
| 139 | #expect(segments.count == 3) | |
| 140 | ||
| 141 | guard case let .plainText(leadingPlainText) = segments[0] else { | |
| 142 | Issue.record("Expected first segment to be plain text") | |
| 143 | return | |
| 144 | } | |
| 145 | #expect(leadingPlainText.contains("From: Christian Cleberg <hello@cleberg.net>")) | |
| 146 | #expect(leadingPlainText.contains("---")) | |
| 147 | #expect(leadingPlainText.contains(" test-patch.txt | 1 +")) | |
| 148 | #expect(leadingPlainText.contains(" 1 file changed, 1 insertion(+)")) | |
| 149 | #expect(leadingPlainText.contains(" create mode 100644 test-patch.txt")) | |
| 150 | ||
| 151 | guard case let .diff(diff) = segments[1] else { | |
| 152 | Issue.record("Expected second segment to be diff") | |
| 153 | return | |
| 154 | } | |
| 155 | #expect(diff.contains("diff --git a/test-patch.txt b/test-patch.txt")) | |
| 156 | #expect(diff.contains("--- /dev/null")) | |
| 157 | #expect(diff.contains("+++ b/test-patch.txt")) | |
| 158 | #expect(diff.contains("+test Wed Mar 18 23:19:03 CDT 2026")) | |
| 159 | #expect(!diff.contains("-- \n2.50.1 (Apple Git-155)")) | |
| 160 | ||
| 161 | guard case let .plainText(trailingPlainText) = segments[2] else { | |
| 162 | Issue.record("Expected third segment to be plain text") | |
| 163 | return | |
| 164 | } | |
| 165 | #expect(trailingPlainText.contains("--")) | |
| 166 | #expect(trailingPlainText.contains("2.50.1 (Apple Git-155)")) | |
| 167 | } | |
| 168 | } | |