Commit 787436c54e
Unsigned
Layout: unified · split
Hutch/App/AppState.swift +14
| @@ -7,6 +7,15 @@ import WebKit | |||
| 7 | @MainActor | 7 | @MainActor |
| 8 | final class AppState { | 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 | enum AuthPhase { | 19 | enum AuthPhase { |
| 11 | /// App just launched, checking for an existing token. | 20 | /// App just launched, checking for an existing token. |
| 12 | case launching | 21 | case launching |
| @@ -25,6 +34,8 @@ final class AppState { | |||
| 25 | authPhase == .authenticated && currentUser != nil | 34 | authPhase == .authenticated && currentUser != nil |
| 26 | } | 35 | } |
| 27 | 36 | ||
| 37 | var selectedTab: Tab = .home | ||
| 38 | |||
| 28 | // MARK: - Current user (populated after successful validation) | 39 | // MARK: - Current user (populated after successful validation) |
| 29 | 40 | ||
| 30 | private(set) var currentUser: User? | 41 | private(set) var currentUser: User? |
| @@ -94,6 +105,7 @@ final class AppState { | |||
| 94 | await clearWebData() | 105 | await clearWebData() |
| 95 | clearWebContentRenderCaches() | 106 | clearWebContentRenderCaches() |
| 96 | authPhase = .unauthenticated | 107 | authPhase = .unauthenticated |
| 108 | selectedTab = .home | ||
| 97 | } | 109 | } |
| 98 | 110 | ||
| 99 | func resetAppData() async { | 111 | func resetAppData() async { |
| @@ -108,6 +120,7 @@ final class AppState { | |||
| 108 | clearWebContentRenderCaches() | 120 | clearWebContentRenderCaches() |
| 109 | 121 | ||
| 110 | authPhase = .unauthenticated | 122 | authPhase = .unauthenticated |
| 123 | selectedTab = .home | ||
| 111 | } | 124 | } |
| 112 | 125 | ||
| 113 | // MARK: - Deep link resolution | 126 | // MARK: - Deep link resolution |
| @@ -208,6 +221,7 @@ final class AppState { | |||
| 208 | client.responseCache.clear() | 221 | client.responseCache.clear() |
| 209 | currentUser = nil | 222 | currentUser = nil |
| 210 | pendingDeepLink = nil | 223 | pendingDeepLink = nil |
| 224 | selectedTab = .home | ||
| 211 | } | 225 | } |
| 212 | 226 | ||
| 213 | private func clearWebData() async { | 227 | private func clearWebData() async { |
Hutch/App/RootView.swift +22 −20
| @@ -4,17 +4,8 @@ import SwiftUI | |||
| 4 | /// full-screen sheet for token entry on first launch. | 4 | /// full-screen sheet for token entry on first launch. |
| 5 | struct RootView: View { | 5 | struct RootView: View { |
| 6 | @Environment(AppState.self) private var appState | 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 | @State private var homePath = NavigationPath() | 7 | @State private var homePath = NavigationPath() |
| 8 | @State private var inboxPath = NavigationPath() | ||
| 18 | @State private var repoPath = NavigationPath() | 9 | @State private var repoPath = NavigationPath() |
| 19 | @State private var buildsPath = NavigationPath() | 10 | @State private var buildsPath = NavigationPath() |
| 20 | @State private var ticketsPath = NavigationPath() | 11 | @State private var ticketsPath = NavigationPath() |
| @@ -48,19 +39,29 @@ struct RootView: View { | |||
| 48 | // MARK: - Tab View | 39 | // MARK: - Tab View |
| 49 | 40 | ||
| 50 | private var tabContent: some View { | 41 | private var tabContent: some View { |
| 51 | TabView(selection: $selectedTab) { | 42 | @Bindable var appState = appState |
| 43 | |||
| 44 | return TabView(selection: $appState.selectedTab) { | ||
| 52 | NavigationStack(path: $homePath) { | 45 | NavigationStack(path: $homePath) { |
| 53 | HomeView() | 46 | HomeView() |
| 54 | } | 47 | } |
| 55 | .tag(Tab.home) | 48 | .tag(AppState.Tab.home) |
| 56 | .tabItem { | 49 | .tabItem { |
| 57 | Label("Home", systemImage: "house") | 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 | NavigationStack(path: $repoPath) { | 61 | NavigationStack(path: $repoPath) { |
| 61 | RepositoryListView() | 62 | RepositoryListView() |
| 62 | } | 63 | } |
| 63 | .tag(Tab.repositories) | 64 | .tag(AppState.Tab.repositories) |
| 64 | .tabItem { | 65 | .tabItem { |
| 65 | Label("Repositories", systemImage: "book.closed") | 66 | Label("Repositories", systemImage: "book.closed") |
| 66 | } | 67 | } |
| @@ -73,7 +74,7 @@ struct RootView: View { | |||
| 73 | BuildDetailView(jobId: jobId) | 74 | BuildDetailView(jobId: jobId) |
| 74 | } | 75 | } |
| 75 | } | 76 | } |
| 76 | .tag(Tab.builds) | 77 | .tag(AppState.Tab.builds) |
| 77 | .tabItem { | 78 | .tabItem { |
| 78 | Label("Builds", systemImage: "hammer") | 79 | Label("Builds", systemImage: "hammer") |
| 79 | } | 80 | } |
| @@ -85,13 +86,13 @@ struct RootView: View { | |||
| 85 | TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) | 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 | .tabItem { | 90 | .tabItem { |
| 90 | Label("Tickets", systemImage: "ticket") | 91 | Label("Tickets", systemImage: "ticket") |
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | SettingsView() | 94 | SettingsView() |
| 94 | .tag(Tab.settings) | 95 | .tag(AppState.Tab.settings) |
| 95 | .tabItem { | 96 | .tabItem { |
| 96 | Label("Settings", systemImage: "gear") | 97 | Label("Settings", systemImage: "gear") |
| 97 | } | 98 | } |
| @@ -117,10 +118,11 @@ struct RootView: View { | |||
| 117 | break | 118 | break |
| 118 | case .unauthenticated: | 119 | case .unauthenticated: |
| 119 | homePath = NavigationPath() | 120 | homePath = NavigationPath() |
| 121 | inboxPath = NavigationPath() | ||
| 120 | repoPath = NavigationPath() | 122 | repoPath = NavigationPath() |
| 121 | buildsPath = NavigationPath() | 123 | buildsPath = NavigationPath() |
| 122 | ticketsPath = NavigationPath() | 124 | ticketsPath = NavigationPath() |
| 123 | selectedTab = .home | 125 | appState.selectedTab = .home |
| 124 | isResolvingDeepLink = false | 126 | isResolvingDeepLink = false |
| 125 | case .authenticated: | 127 | case .authenticated: |
| 126 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) | 128 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) |
| @@ -143,7 +145,7 @@ struct RootView: View { | |||
| 143 | case .build(let jobId): | 145 | case .build(let jobId): |
| 144 | // Reset the builds navigation and push the detail | 146 | // Reset the builds navigation and push the detail |
| 145 | buildsPath = NavigationPath() | 147 | buildsPath = NavigationPath() |
| 146 | selectedTab = .builds | 148 | appState.selectedTab = .builds |
| 147 | // Defer the push slightly so the tab switch takes effect | 149 | // Defer the push slightly so the tab switch takes effect |
| 148 | Task { @MainActor in | 150 | Task { @MainActor in |
| 149 | try? await Task.sleep(for: .milliseconds(100)) | 151 | try? await Task.sleep(for: .milliseconds(100)) |
| @@ -162,7 +164,7 @@ struct RootView: View { | |||
| 162 | do { | 164 | do { |
| 163 | let summary = try await appState.resolveRepository(owner: owner, name: repo) | 165 | let summary = try await appState.resolveRepository(owner: owner, name: repo) |
| 164 | repoPath = NavigationPath() | 166 | repoPath = NavigationPath() |
| 165 | selectedTab = .repositories | 167 | appState.selectedTab = .repositories |
| 166 | try? await Task.sleep(for: .milliseconds(100)) | 168 | try? await Task.sleep(for: .milliseconds(100)) |
| 167 | repoPath.append(summary) | 169 | repoPath.append(summary) |
| 168 | } catch { | 170 | } catch { |
| @@ -178,7 +180,7 @@ struct RootView: View { | |||
| 178 | do { | 180 | do { |
| 179 | let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) | 181 | let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) |
| 180 | ticketsPath = NavigationPath() | 182 | ticketsPath = NavigationPath() |
| 181 | selectedTab = .tickets | 183 | appState.selectedTab = .tickets |
| 182 | try? await Task.sleep(for: .milliseconds(100)) | 184 | try? await Task.sleep(for: .milliseconds(100)) |
| 183 | ticketsPath.append(trackerSummary) | 185 | ticketsPath.append(trackerSummary) |
| 184 | try? await Task.sleep(for: .milliseconds(100)) | 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 | } header: { | 117 | } header: { |
| 118 | HomeSectionHeader("Recent Builds") { | 118 | HomeSectionActionHeader("Recent Builds") { |
| 119 | BuildListView() | 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 | private struct HomeAssignedTicketsListView: View { | 267 | private struct HomeAssignedTicketsListView: View { |
| 247 | let viewModel: HomeViewModel | 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 | let diff: String | 9 | let diff: String |
| 10 | 10 | ||
| 11 | var body: some View { | 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 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in | 15 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in |
| 16 | DiffLineView(line: line) | 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 | Text(line.isEmpty ? " " : line) | 36 | Text(line.isEmpty ? " " : line) |
| 28 | .frame(maxWidth: .infinity, alignment: .leading) | 37 | .frame(maxWidth: .infinity, alignment: .leading) |
| 29 | .padding(.horizontal, 8) | 38 | .padding(.horizontal, 8) |
| 30 | .padding(.vertical, 1) | ||
| 31 | .background(backgroundColor) | 39 | .background(backgroundColor) |
| 32 | .foregroundStyle(foregroundColor) | 40 | .foregroundStyle(foregroundColor) |
| 33 | .fontWeight(isHeader ? .semibold : .regular) | 41 | .fontWeight(isHeader ? .semibold : .regular) |
| @@ -47,9 +55,9 @@ private struct DiffLineView: View { | |||
| 47 | switch kind { | 55 | switch kind { |
| 48 | case .added: .green.opacity(0.15) | 56 | case .added: .green.opacity(0.15) |
| 49 | case .removed: .red.opacity(0.15) | 57 | case .removed: .red.opacity(0.15) |
| 50 | case .hunk: .gray.opacity(0.12) | 58 | case .hunk: .clear |
| 51 | case .fileHeader: .gray.opacity(0.08) | 59 | case .fileHeader: .clear |
| 52 | case .meta: .gray.opacity(0.05) | 60 | case .meta: .clear |
| 53 | case .context: .clear | 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 | } | ||