Commit 023f46c63e
Unsigned
Layout: unified · split
Hutch/App/RootView.swift +3
| @@ -439,6 +439,7 @@ enum MoreRoute: Hashable { | ||
| 439 | 439 | case projectDashboard(id: String, title: String?) |
| 440 | 440 | case mailingList(InboxMailingListReference) |
| 441 | 441 | case thread(InboxThreadSummary) |
| 442 | case patchset(id: Int, listName: String?) | |
| 442 | 443 | case manPageBrowser |
| 443 | 444 | case manPage(URL) |
| 444 | 445 | } |
| @@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View { | ||
| 472 | 473 | ProjectDashboardDeepLinkView(projectID: id, title: title) |
| 473 | 474 | case .mailingList(let mailingList): |
| 474 | 475 | MailingListDetailView(mailingList: mailingList) |
| 476 | case .patchset(let id, let listName): | |
| 477 | PatchsetDetailView(patchsetID: id, listName: listName) | |
| 475 | 478 | case .thread(let thread): |
| 476 | 479 | ThreadDetailView( |
| 477 | 480 | thread: thread, |
Hutch/Views/Lookup/LookupView.swift +2
| @@ -465,6 +465,8 @@ struct LookupView: View { | ||
| 465 | 465 | ProjectDashboardDeepLinkView(projectID: id, title: title) |
| 466 | 466 | case .mailingList(let mailingList): |
| 467 | 467 | MailingListDetailView(mailingList: mailingList) |
| 468 | case .patchset(let id, let listName): | |
| 469 | PatchsetDetailView(patchsetID: id, listName: listName) | |
| 468 | 470 | case .thread(let thread): |
| 469 | 471 | ThreadDetailView( |
| 470 | 472 | thread: thread, |
Hutch/Views/Patchsets/PatchsetDetailView.swift added +248
| @@ -0,0 +1,248 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct PatchsetDetailView: View { | |
| 4 | let patchsetID: Int | |
| 5 | let listName: String? | |
| 6 | ||
| 7 | @Environment(AppState.self) private var appState | |
| 8 | @State private var viewModel: PatchsetDetailViewModel? | |
| 9 | @State private var showStatusPicker = false | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | Group { | |
| 13 | if let viewModel { | |
| 14 | content(viewModel) | |
| 15 | } else { | |
| 16 | SRHTLoadingStateView(message: "Loading Patchset…") | |
| 17 | } | |
| 18 | } | |
| 19 | .navigationTitle("Patchset") | |
| 20 | .navigationBarTitleDisplayMode(.inline) | |
| 21 | .toolbar { | |
| 22 | if let viewModel, viewModel.patchset != nil { | |
| 23 | ToolbarItem(placement: .topBarTrailing) { | |
| 24 | actionsMenu(viewModel) | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | .task { | |
| 29 | let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client) | |
| 30 | viewModel = model | |
| 31 | await model.loadPatchset() | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | @ViewBuilder | |
| 36 | private func content(_ viewModel: PatchsetDetailViewModel) -> some View { | |
| 37 | if viewModel.isLoading && viewModel.patchset == nil { | |
| 38 | SRHTLoadingStateView(message: "Loading Patchset…") | |
| 39 | } else if let patchset = viewModel.patchset { | |
| 40 | List { | |
| 41 | headerSection(patchset) | |
| 42 | if let coverLetter = patchset.coverLetter { | |
| 43 | emailSection(coverLetter, title: "Cover Letter") | |
| 44 | } | |
| 45 | if !patchset.tools.isEmpty { | |
| 46 | toolsSection(patchset) | |
| 47 | } | |
| 48 | patchesSection(patchset) | |
| 49 | } | |
| 50 | .themedList() | |
| 51 | .refreshable { await viewModel.loadPatchset() } | |
| 52 | .overlay { | |
| 53 | if viewModel.isUpdatingStatus { | |
| 54 | ProgressView() | |
| 55 | } | |
| 56 | } | |
| 57 | .confirmationDialog( | |
| 58 | "Set Status", | |
| 59 | isPresented: $showStatusPicker, | |
| 60 | titleVisibility: .visible | |
| 61 | ) { | |
| 62 | ForEach(PatchsetStatus.assignable, id: \.self) { status in | |
| 63 | Button(status.displayName) { | |
| 64 | Task { await viewModel.updateStatus(to: status) } | |
| 65 | } | |
| 66 | } | |
| 67 | Button("Cancel", role: .cancel) {} | |
| 68 | } | |
| 69 | .alert( | |
| 70 | "Couldn't Update Patchset", | |
| 71 | isPresented: .init( | |
| 72 | get: { viewModel.error != nil }, | |
| 73 | set: { if !$0 { viewModel.error = nil } } | |
| 74 | ) | |
| 75 | ) { | |
| 76 | Button("OK", role: .cancel) { viewModel.error = nil } | |
| 77 | } message: { | |
| 78 | Text(viewModel.error ?? "") | |
| 79 | } | |
| 80 | } else if let error = viewModel.error { | |
| 81 | SRHTErrorStateView( | |
| 82 | title: "Couldn't Load Patchset", | |
| 83 | message: error, | |
| 84 | retryAction: { await viewModel.loadPatchset() } | |
| 85 | ) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | // MARK: - Sections | |
| 90 | ||
| 91 | @ViewBuilder | |
| 92 | private func headerSection(_ patchset: PatchsetDetail) -> some View { | |
| 93 | Section { | |
| 94 | VStack(alignment: .leading, spacing: 8) { | |
| 95 | Text(patchset.subject) | |
| 96 | .font(.headline) | |
| 97 | ||
| 98 | HStack(spacing: 8) { | |
| 99 | PatchsetStatusBadge(status: patchset.status) | |
| 100 | if patchset.version > 1 { | |
| 101 | Text("v\(patchset.version)") | |
| 102 | .font(.caption.weight(.medium)) | |
| 103 | .foregroundStyle(.secondary) | |
| 104 | } | |
| 105 | Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")") | |
| 106 | .font(.caption) | |
| 107 | .foregroundStyle(.secondary) | |
| 108 | } | |
| 109 | ||
| 110 | Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)") | |
| 111 | .font(.caption) | |
| 112 | .foregroundStyle(.secondary) | |
| 113 | ||
| 114 | if let listName { | |
| 115 | Text(listName) | |
| 116 | .font(.caption) | |
| 117 | .foregroundStyle(.secondary) | |
| 118 | } | |
| 119 | } | |
| 120 | .padding(.vertical, 2) | |
| 121 | .themedRow() | |
| 122 | ||
| 123 | // The version chain matters during review: a superseded series should | |
| 124 | // usually be read at its newest version instead. | |
| 125 | if let supersededBy = patchset.supersededBy { | |
| 126 | NavigationLink(value: MoreRoute.patchset(id: supersededBy, listName: listName)) { | |
| 127 | SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle") | |
| 128 | .font(.subheadline) | |
| 129 | } | |
| 130 | .themedRow() | |
| 131 | } | |
| 132 | ||
| 133 | if let supersedes = patchset.supersedes { | |
| 134 | NavigationLink(value: MoreRoute.patchset(id: supersedes, listName: listName)) { | |
| 135 | SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle") | |
| 136 | .font(.subheadline) | |
| 137 | } | |
| 138 | .themedRow() | |
| 139 | } | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | @ViewBuilder | |
| 144 | private func toolsSection(_ patchset: PatchsetDetail) -> some View { | |
| 145 | Section("Checks") { | |
| 146 | ForEach(patchset.tools) { tool in | |
| 147 | HStack(spacing: 8) { | |
| 148 | Image(systemName: tool.icon.systemImage) | |
| 149 | .foregroundStyle(tool.icon == .failed ? .red : .secondary) | |
| 150 | Text(tool.details) | |
| 151 | .font(.subheadline) | |
| 152 | } | |
| 153 | .themedRow() | |
| 154 | } | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | @ViewBuilder | |
| 159 | private func patchesSection(_ patchset: PatchsetDetail) -> some View { | |
| 160 | ForEach(patchset.patches) { patch in | |
| 161 | emailSection(patch, title: patch.seriesLabel.map { "Patch \($0)" } ?? "Patch") | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | @ViewBuilder | |
| 166 | private func emailSection(_ email: PatchsetEmail, title: String) -> some View { | |
| 167 | Section(title) { | |
| 168 | VStack(alignment: .leading, spacing: 10) { | |
| 169 | Text(email.subject) | |
| 170 | .font(.subheadline.weight(.semibold)) | |
| 171 | .textSelection(.enabled) | |
| 172 | ||
| 173 | ForEach(Array(email.contentBlocks.enumerated()), id: \.offset) { _, block in | |
| 174 | switch block { | |
| 175 | case .plainText(let text): | |
| 176 | Text(text) | |
| 177 | .font(.body) | |
| 178 | .textSelection(.enabled) | |
| 179 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 180 | .fixedSize(horizontal: false, vertical: true) | |
| 181 | case .diff(let diff): | |
| 182 | DiffView(diff: diff) | |
| 183 | .textSelection(.enabled) | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | .padding(.vertical, 4) | |
| 188 | .themedRow() | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | // MARK: - Actions | |
| 193 | ||
| 194 | @ViewBuilder | |
| 195 | private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View { | |
| 196 | Menu { | |
| 197 | Button { | |
| 198 | showStatusPicker = true | |
| 199 | } label: { | |
| 200 | SwiftUI.Label("Set Status", systemImage: "flag") | |
| 201 | } | |
| 202 | .disabled(viewModel.isUpdatingStatus) | |
| 203 | ||
| 204 | if let mbox = viewModel.patchset?.mbox { | |
| 205 | Divider() | |
| 206 | ShareLink(item: mbox) { | |
| 207 | SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up") | |
| 208 | } | |
| 209 | Button { | |
| 210 | appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL") | |
| 211 | } label: { | |
| 212 | SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc") | |
| 213 | } | |
| 214 | } | |
| 215 | } label: { | |
| 216 | Image(systemName: "ellipsis.circle") | |
| 217 | } | |
| 218 | .accessibilityLabel("Patchset actions") | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | // MARK: - Status Badge | |
| 223 | ||
| 224 | struct PatchsetStatusBadge: View { | |
| 225 | let status: PatchsetStatus | |
| 226 | ||
| 227 | var body: some View { | |
| 228 | SwiftUI.Label(status.displayName, systemImage: status.systemImage) | |
| 229 | .font(.caption.weight(.medium)) | |
| 230 | .padding(.horizontal, 8) | |
| 231 | .padding(.vertical, 3) | |
| 232 | .background(background, in: Capsule()) | |
| 233 | .foregroundStyle(foreground) | |
| 234 | } | |
| 235 | ||
| 236 | private var foreground: Color { | |
| 237 | switch status { | |
| 238 | case .applied, .approved: .green | |
| 239 | case .rejected: .red | |
| 240 | case .needsRevision: .orange | |
| 241 | case .superseded, .unknown, .proposed: .secondary | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | private var background: Color { | |
| 246 | foreground.opacity(0.12) | |
| 247 | } | |
| 248 | } | |
Hutch/Views/Patchsets/PatchsetDetailViewModel.swift added +293
| @@ -0,0 +1,293 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct PatchsetDetailResponse: Decodable, Sendable { | |
| 6 | let patchset: PatchsetDetailPayload? | |
| 7 | } | |
| 8 | ||
| 9 | private struct PatchsetDetailPayload: Decodable, Sendable { | |
| 10 | let id: Int | |
| 11 | let created: Date | |
| 12 | let updated: Date | |
| 13 | let subject: String | |
| 14 | let version: Int | |
| 15 | let prefix: String? | |
| 16 | let status: PatchsetStatus | |
| 17 | let submitter: Entity | |
| 18 | let coverLetter: PatchsetEmailPayload? | |
| 19 | let supersededBy: PatchsetReferencePayload? | |
| 20 | let supersedes: PatchsetReferencePayload? | |
| 21 | let patches: PatchsetPatchPage | |
| 22 | let tools: [PatchsetToolPayload] | |
| 23 | let mbox: URL? | |
| 24 | } | |
| 25 | ||
| 26 | private struct PatchsetReferencePayload: Decodable, Sendable { | |
| 27 | let id: Int | |
| 28 | } | |
| 29 | ||
| 30 | private struct PatchsetPatchPage: Decodable, Sendable { | |
| 31 | let results: [PatchsetEmailPayload] | |
| 32 | let cursor: String? | |
| 33 | } | |
| 34 | ||
| 35 | private struct PatchsetEmailPayload: Decodable, Sendable { | |
| 36 | let id: Int | |
| 37 | let subject: String | |
| 38 | let date: Date? | |
| 39 | let sender: Entity | |
| 40 | let body: String | |
| 41 | let patch: PatchIndexPayload? | |
| 42 | } | |
| 43 | ||
| 44 | private struct PatchIndexPayload: Decodable, Sendable { | |
| 45 | let index: Int? | |
| 46 | let count: Int? | |
| 47 | } | |
| 48 | ||
| 49 | private struct PatchsetToolPayload: Decodable, Sendable { | |
| 50 | let id: Int | |
| 51 | let icon: PatchsetToolIcon | |
| 52 | let details: String | |
| 53 | } | |
| 54 | ||
| 55 | private struct UpdatePatchsetResponse: Decodable, Sendable { | |
| 56 | let patchset: UpdatedPatchsetPayload? | |
| 57 | } | |
| 58 | ||
| 59 | private struct UpdatedPatchsetPayload: Decodable, Sendable { | |
| 60 | let status: PatchsetStatus | |
| 61 | } | |
| 62 | ||
| 63 | // MARK: - View Model | |
| 64 | ||
| 65 | @Observable | |
| 66 | @MainActor | |
| 67 | final class PatchsetDetailViewModel { | |
| 68 | ||
| 69 | let patchsetID: Int | |
| 70 | ||
| 71 | private(set) var patchset: PatchsetDetail? | |
| 72 | private(set) var isLoading = false | |
| 73 | private(set) var isUpdatingStatus = false | |
| 74 | var error: String? | |
| 75 | ||
| 76 | private let client: SRHTClient | |
| 77 | ||
| 78 | init(patchsetID: Int, client: SRHTClient) { | |
| 79 | self.patchsetID = patchsetID | |
| 80 | self.client = client | |
| 81 | } | |
| 82 | ||
| 83 | // MARK: - Queries | |
| 84 | ||
| 85 | /// `patches` is paginated, but a series is small and reviewing half of one is | |
| 86 | /// worse than useless, so every page is walked before rendering. | |
| 87 | private static let detailQuery = """ | |
| 88 | query patchset($id: Int!, $cursor: Cursor) { | |
| 89 | patchset(id: $id) { | |
| 90 | id | |
| 91 | created | |
| 92 | updated | |
| 93 | subject | |
| 94 | version | |
| 95 | prefix | |
| 96 | status | |
| 97 | submitter { canonicalName } | |
| 98 | supersededBy { id } | |
| 99 | supersedes { id } | |
| 100 | coverLetter { | |
| 101 | id | |
| 102 | subject | |
| 103 | date | |
| 104 | sender { canonicalName } | |
| 105 | body | |
| 106 | patch { index count } | |
| 107 | } | |
| 108 | patches(cursor: $cursor) { | |
| 109 | results { | |
| 110 | id | |
| 111 | subject | |
| 112 | date | |
| 113 | sender { canonicalName } | |
| 114 | body | |
| 115 | patch { index count } | |
| 116 | } | |
| 117 | cursor | |
| 118 | } | |
| 119 | tools { id icon details } | |
| 120 | mbox | |
| 121 | } | |
| 122 | } | |
| 123 | """ | |
| 124 | ||
| 125 | private static let updateStatusMutation = """ | |
| 126 | mutation updatePatchset($id: Int!, $status: PatchsetStatus!) { | |
| 127 | patchset: updatePatchset(id: $id, status: $status) { | |
| 128 | status | |
| 129 | } | |
| 130 | } | |
| 131 | """ | |
| 132 | ||
| 133 | // MARK: - Loading | |
| 134 | ||
| 135 | func loadPatchset() async { | |
| 136 | guard !isLoading else { return } | |
| 137 | isLoading = true | |
| 138 | error = nil | |
| 139 | defer { isLoading = false } | |
| 140 | ||
| 141 | do { | |
| 142 | patchset = try await fetchPatchset() | |
| 143 | } catch { | |
| 144 | self.error = error.userFacingMessage | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | private func fetchPatchset() async throws -> PatchsetDetail { | |
| 149 | var cursor: String? | |
| 150 | var payload: PatchsetDetailPayload? | |
| 151 | var patches: [PatchsetEmailPayload] = [] | |
| 152 | ||
| 153 | // Walk the patches pages, keeping the first page's patchset fields. | |
| 154 | while true { | |
| 155 | var variables: [String: any Sendable] = ["id": patchsetID] | |
| 156 | if let cursor { | |
| 157 | variables["cursor"] = cursor | |
| 158 | } | |
| 159 | ||
| 160 | let response = try await client.execute( | |
| 161 | service: .lists, | |
| 162 | query: Self.detailQuery, | |
| 163 | variables: variables, | |
| 164 | responseType: PatchsetDetailResponse.self | |
| 165 | ) | |
| 166 | ||
| 167 | guard let page = response.patchset else { | |
| 168 | throw SRHTError.graphQLErrors([ | |
| 169 | GraphQLError(message: "That patchset is no longer available.", locations: nil) | |
| 170 | ]) | |
| 171 | } | |
| 172 | ||
| 173 | if payload == nil { | |
| 174 | payload = page | |
| 175 | } | |
| 176 | patches.append(contentsOf: page.patches.results) | |
| 177 | ||
| 178 | guard let next = page.patches.cursor, !next.isEmpty else { break } | |
| 179 | cursor = next | |
| 180 | } | |
| 181 | ||
| 182 | guard let payload else { | |
| 183 | throw SRHTError.graphQLErrors([ | |
| 184 | GraphQLError(message: "That patchset is no longer available.", locations: nil) | |
| 185 | ]) | |
| 186 | } | |
| 187 | ||
| 188 | return PatchsetDetail( | |
| 189 | id: payload.id, | |
| 190 | created: payload.created, | |
| 191 | updated: payload.updated, | |
| 192 | subject: payload.subject, | |
| 193 | version: payload.version, | |
| 194 | prefix: payload.prefix, | |
| 195 | status: payload.status, | |
| 196 | submitter: payload.submitter, | |
| 197 | coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) }, | |
| 198 | patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }), | |
| 199 | supersededBy: payload.supersededBy?.id, | |
| 200 | supersedes: payload.supersedes?.id, | |
| 201 | tools: payload.tools.map { | |
| 202 | PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details) | |
| 203 | }, | |
| 204 | mbox: payload.mbox | |
| 205 | ) | |
| 206 | } | |
| 207 | ||
| 208 | // MARK: - Status | |
| 209 | ||
| 210 | /// Sets the review status. Returns true on success. | |
| 211 | @discardableResult | |
| 212 | func updateStatus(to newStatus: PatchsetStatus) async -> Bool { | |
| 213 | guard !isUpdatingStatus, let current = patchset else { return false } | |
| 214 | guard newStatus != current.status else { return true } | |
| 215 | ||
| 216 | isUpdatingStatus = true | |
| 217 | error = nil | |
| 218 | defer { isUpdatingStatus = false } | |
| 219 | ||
| 220 | do { | |
| 221 | let response = try await client.execute( | |
| 222 | service: .lists, | |
| 223 | query: Self.updateStatusMutation, | |
| 224 | variables: [ | |
| 225 | "id": patchsetID, | |
| 226 | "status": newStatus.rawValue | |
| 227 | ], | |
| 228 | responseType: UpdatePatchsetResponse.self | |
| 229 | ) | |
| 230 | ||
| 231 | // updatePatchset is nullable: null means the server declined without | |
| 232 | // erroring, so the local status must not be advanced. | |
| 233 | guard let updated = response.patchset else { | |
| 234 | self.error = "SourceHut did not apply that status change." | |
| 235 | return false | |
| 236 | } | |
| 237 | ||
| 238 | apply(status: updated.status) | |
| 239 | return true | |
| 240 | } catch { | |
| 241 | self.error = error.userFacingMessage | |
| 242 | return false | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | private func apply(status: PatchsetStatus) { | |
| 247 | guard let current = patchset else { return } | |
| 248 | patchset = PatchsetDetail( | |
| 249 | id: current.id, | |
| 250 | created: current.created, | |
| 251 | updated: current.updated, | |
| 252 | subject: current.subject, | |
| 253 | version: current.version, | |
| 254 | prefix: current.prefix, | |
| 255 | status: status, | |
| 256 | submitter: current.submitter, | |
| 257 | coverLetter: current.coverLetter, | |
| 258 | patches: current.patches, | |
| 259 | supersededBy: current.supersededBy, | |
| 260 | supersedes: current.supersedes, | |
| 261 | tools: current.tools, | |
| 262 | mbox: current.mbox | |
| 263 | ) | |
| 264 | } | |
| 265 | ||
| 266 | // MARK: - Mapping | |
| 267 | ||
| 268 | private nonisolated static func makeEmail( | |
| 269 | from payload: PatchsetEmailPayload, | |
| 270 | isPatch: Bool | |
| 271 | ) -> PatchsetEmail { | |
| 272 | PatchsetEmail( | |
| 273 | id: payload.id, | |
| 274 | subject: payload.subject, | |
| 275 | date: payload.date, | |
| 276 | sender: payload.sender, | |
| 277 | contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch), | |
| 278 | index: payload.patch?.index, | |
| 279 | count: payload.patch?.count | |
| 280 | ) | |
| 281 | } | |
| 282 | ||
| 283 | /// Orders a series by its `[PATCH n/m]` index. | |
| 284 | /// | |
| 285 | /// sr.ht returns patches in receipt order, which is not series order when a | |
| 286 | /// contributor's mail arrives out of sequence. Patches without an index keep | |
| 287 | /// their relative position at the end rather than being dropped. | |
| 288 | nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] { | |
| 289 | let indexed = patches.filter { $0.index != nil } | |
| 290 | let unindexed = patches.filter { $0.index == nil } | |
| 291 | return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed | |
| 292 | } | |
| 293 | } | |
Hutch/Views/Projects/ProjectMailingListView.swift +123 −1
| @@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable { | ||
| 24 | 24 | let id: Int |
| 25 | 25 | let messageID: String |
| 26 | 26 | let patch: InboxPatchPreview? |
| 27 | /// Null unless the thread's root email opens a patchset. `MailingList` has no | |
| 28 | /// patchsets field, so this is the only way to enumerate a list's patchsets. | |
| 29 | let patchset: PatchsetSummaryPayload? | |
| 30 | } | |
| 31 | ||
| 32 | private struct PatchsetSummaryPayload: Decodable, Sendable { | |
| 33 | let id: Int | |
| 34 | let subject: String | |
| 35 | let version: Int | |
| 36 | let prefix: String? | |
| 37 | let status: PatchsetStatus | |
| 27 | 38 | } |
| 28 | 39 | |
| 29 | 40 | @Observable |
| 30 | 41 | @MainActor |
| 31 | 42 | final class MailingListDetailViewModel { |
| 32 | 43 | private(set) var threads: [InboxThreadSummary] = [] |
| 44 | /// Patchsets on this list, derived from thread roots — see the query below. | |
| 45 | private(set) var patchsets: [PatchsetSummary] = [] | |
| 33 | 46 | private(set) var isLoading = false |
| 34 | 47 | var error: String? |
| 35 | 48 | var searchText = "" |
| @@ -52,6 +65,13 @@ final class MailingListDetailViewModel { | ||
| 52 | 65 | id |
| 53 | 66 | messageID |
| 54 | 67 | patch { subject } |
| 68 | patchset { | |
| 69 | id | |
| 70 | subject | |
| 71 | version | |
| 72 | prefix | |
| 73 | status | |
| 74 | } | |
| 55 | 75 | } |
| 56 | 76 | } |
| 57 | 77 | } |
| @@ -87,11 +107,46 @@ final class MailingListDetailViewModel { | ||
| 87 | 107 | threads = deduplicateThreads( |
| 88 | 108 | response.list.threads.results.map(makeSummary(from:)) |
| 89 | 109 | ) |
| 110 | patchsets = Self.patchsets(from: response.list.threads.results) | |
| 90 | 111 | } catch { |
| 91 | 112 | self.error = "Failed to load mailing list" |
| 92 | 113 | } |
| 93 | 114 | } |
| 94 | 115 | |
| 116 | var filteredPatchsets: [PatchsetSummary] { | |
| 117 | let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| 118 | guard !query.isEmpty else { return patchsets } | |
| 119 | return patchsets.filter { $0.subject.lowercased().contains(query) } | |
| 120 | } | |
| 121 | ||
| 122 | /// Collects the patchsets opened by these threads, newest first. | |
| 123 | /// | |
| 124 | /// A revised series arrives as its own thread, so the same subject can appear | |
| 125 | /// at several versions; they are kept as distinct patchsets and the version | |
| 126 | /// chain is shown in the detail view. | |
| 127 | private nonisolated static func patchsets( | |
| 128 | from threads: [ProjectMailingListThreadPayload] | |
| 129 | ) -> [PatchsetSummary] { | |
| 130 | var seenIDs = Set<Int>() | |
| 131 | var results: [PatchsetSummary] = [] | |
| 132 | ||
| 133 | for thread in threads { | |
| 134 | guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue } | |
| 135 | seenIDs.insert(payload.id) | |
| 136 | results.append( | |
| 137 | PatchsetSummary( | |
| 138 | id: payload.id, | |
| 139 | subject: payload.subject, | |
| 140 | version: payload.version, | |
| 141 | prefix: payload.prefix, | |
| 142 | status: payload.status | |
| 143 | ) | |
| 144 | ) | |
| 145 | } | |
| 146 | ||
| 147 | return results | |
| 148 | } | |
| 149 | ||
| 95 | 150 | func markThreadRead(_ thread: InboxThreadSummary) { |
| 96 | 151 | let viewedAt = max(Date(), thread.lastActivityAt) |
| 97 | 152 | InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults) |
| @@ -257,12 +312,25 @@ final class MailingListDetailViewModel { | ||
| 257 | 312 | } |
| 258 | 313 | } |
| 259 | 314 | |
| 315 | enum MailingListScope: String, CaseIterable, Hashable { | |
| 316 | case threads | |
| 317 | case patches | |
| 318 | ||
| 319 | var displayName: String { | |
| 320 | switch self { | |
| 321 | case .threads: "Threads" | |
| 322 | case .patches: "Patches" | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 260 | 327 | struct MailingListDetailView: View { |
| 261 | 328 | let mailingList: InboxMailingListReference |
| 262 | 329 | |
| 263 | 330 | @Environment(AppState.self) private var appState |
| 264 | 331 | @State private var viewModel: MailingListDetailViewModel? |
| 265 | 332 | @State private var pinChangeCount = 0 |
| 333 | @State private var scope: MailingListScope = .threads | |
| 266 | 334 | |
| 267 | 335 | private var currentUserKey: String? { |
| 268 | 336 | appState.currentUser?.canonicalName |
| @@ -337,6 +405,27 @@ struct MailingListDetailView: View { | ||
| 337 | 405 | @Bindable var vm = viewModel |
| 338 | 406 | |
| 339 | 407 | List { |
| 408 | // Only offered when the list actually carries patches, so discussion | |
| 409 | // lists do not grow an empty tab. | |
| 410 | if !viewModel.patchsets.isEmpty { | |
| 411 | Picker("Scope", selection: $scope) { | |
| 412 | ForEach(MailingListScope.allCases, id: \.self) { scope in | |
| 413 | Text(scope.displayName).tag(scope) | |
| 414 | } | |
| 415 | } | |
| 416 | .pickerStyle(.segmented) | |
| 417 | .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) | |
| 418 | .themedRow() | |
| 419 | } | |
| 420 | ||
| 421 | if showingPatches(viewModel) { | |
| 422 | ForEach(viewModel.filteredPatchsets) { patchset in | |
| 423 | NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) { | |
| 424 | PatchsetRow(patchset: patchset) | |
| 425 | } | |
| 426 | .themedRow() | |
| 427 | } | |
| 428 | } else { | |
| 340 | 429 | ForEach(viewModel.filteredThreads) { thread in |
| 341 | 430 | NavigationLink { |
| 342 | 431 | ThreadDetailView( |
| @@ -373,13 +462,14 @@ struct MailingListDetailView: View { | ||
| 373 | 462 | } |
| 374 | 463 | } |
| 375 | 464 | .themedRow() |
| 465 | } | |
| 376 | 466 | } |
| 377 | 467 | .themedList() |
| 378 | 468 | .listStyle(.plain) |
| 379 | 469 | .searchable( |
| 380 | 470 | text: $vm.searchText, |
| 381 | 471 | placement: .navigationBarDrawer(displayMode: .always), |
| 382 | prompt: "Search messages" | |
| 472 | prompt: showingPatches(viewModel) ? "Search patches" : "Search messages" | |
| 383 | 473 | ) |
| 384 | 474 | .overlay { |
| 385 | 475 | if viewModel.isLoading, viewModel.threads.isEmpty { |
| @@ -390,6 +480,10 @@ struct MailingListDetailView: View { | ||
| 390 | 480 | message: error, |
| 391 | 481 | retryAction: { await viewModel.loadThreads() } |
| 392 | 482 | ) |
| 483 | } else if showingPatches(viewModel) { | |
| 484 | if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty { | |
| 485 | ContentUnavailableView.search(text: viewModel.searchText) | |
| 486 | } | |
| 393 | 487 | } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { |
| 394 | 488 | ContentUnavailableView.search(text: viewModel.searchText) |
| 395 | 489 | } else if viewModel.threads.isEmpty { |
| @@ -405,6 +499,34 @@ struct MailingListDetailView: View { | ||
| 405 | 499 | } |
| 406 | 500 | .srhtErrorBanner(error: $vm.error) |
| 407 | 501 | } |
| 502 | ||
| 503 | private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool { | |
| 504 | scope == .patches && !viewModel.patchsets.isEmpty | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | struct PatchsetRow: View { | |
| 509 | let patchset: PatchsetSummary | |
| 510 | ||
| 511 | var body: some View { | |
| 512 | VStack(alignment: .leading, spacing: 6) { | |
| 513 | Text(patchset.subject) | |
| 514 | .font(.subheadline.weight(.medium)) | |
| 515 | .lineLimit(2) | |
| 516 | ||
| 517 | HStack(spacing: 8) { | |
| 518 | PatchsetStatusBadge(status: patchset.status) | |
| 519 | if let versionLabel = patchset.versionLabel { | |
| 520 | Text(versionLabel) | |
| 521 | .font(.caption.weight(.medium)) | |
| 522 | .foregroundStyle(.secondary) | |
| 523 | } | |
| 524 | } | |
| 525 | } | |
| 526 | .padding(.vertical, 2) | |
| 527 | .accessibilityElement(children: .combine) | |
| 528 | .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)") | |
| 529 | } | |
| 408 | 530 | } |
| 409 | 531 | |
| 410 | 532 | struct ProjectMailingListView: View { |
HutchTests/PatchsetTests.swift added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct PatchsetStatusTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | func statusRawValuesMatchTheGraphQLEnum() { | |
| 9 | // lists.sr.ht's PatchsetStatus enum values, which are sent verbatim to | |
| 10 | // updatePatchset. | |
| 11 | #expect(PatchsetStatus.unknown.rawValue == "UNKNOWN") | |
| 12 | #expect(PatchsetStatus.proposed.rawValue == "PROPOSED") | |
| 13 | #expect(PatchsetStatus.needsRevision.rawValue == "NEEDS_REVISION") | |
| 14 | #expect(PatchsetStatus.superseded.rawValue == "SUPERSEDED") | |
| 15 | #expect(PatchsetStatus.approved.rawValue == "APPROVED") | |
| 16 | #expect(PatchsetStatus.rejected.rawValue == "REJECTED") | |
| 17 | #expect(PatchsetStatus.applied.rawValue == "APPLIED") | |
| 18 | } | |
| 19 | ||
| 20 | @Test | |
| 21 | func assignableStatusesExcludeServerManagedOnes() { | |
| 22 | // UNKNOWN is a sentinel and SUPERSEDED is set by the server when a newer | |
| 23 | // version lands, so neither should be offered as a reviewer choice. | |
| 24 | #expect(!PatchsetStatus.assignable.contains(.unknown)) | |
| 25 | #expect(!PatchsetStatus.assignable.contains(.superseded)) | |
| 26 | #expect(PatchsetStatus.assignable.contains(.approved)) | |
| 27 | #expect(PatchsetStatus.assignable.contains(.rejected)) | |
| 28 | #expect(PatchsetStatus.assignable.contains(.applied)) | |
| 29 | #expect(PatchsetStatus.assignable.contains(.needsRevision)) | |
| 30 | #expect(PatchsetStatus.assignable.contains(.proposed)) | |
| 31 | } | |
| 32 | ||
| 33 | @Test | |
| 34 | func openStatusesAreThoseAwaitingADecision() { | |
| 35 | #expect(PatchsetStatus.proposed.isOpen) | |
| 36 | #expect(PatchsetStatus.needsRevision.isOpen) | |
| 37 | #expect(!PatchsetStatus.applied.isOpen) | |
| 38 | #expect(!PatchsetStatus.rejected.isOpen) | |
| 39 | #expect(!PatchsetStatus.superseded.isOpen) | |
| 40 | } | |
| 41 | ||
| 42 | @Test | |
| 43 | func statusDecodesFromTheWireFormat() throws { | |
| 44 | let decoded = try JSONDecoder().decode(PatchsetStatus.self, from: Data("\"NEEDS_REVISION\"".utf8)) | |
| 45 | #expect(decoded == .needsRevision) | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | struct PatchsetSummaryTests { | |
| 50 | ||
| 51 | @Test | |
| 52 | func versionLabelIsHiddenForFirstVersion() { | |
| 53 | let summary = PatchsetSummary( | |
| 54 | id: 1, | |
| 55 | subject: "[PATCH] fix the thing", | |
| 56 | version: 1, | |
| 57 | prefix: nil, | |
| 58 | status: .proposed | |
| 59 | ) | |
| 60 | ||
| 61 | #expect(summary.versionLabel == nil) | |
| 62 | } | |
| 63 | ||
| 64 | @Test | |
| 65 | func versionLabelIsShownForRevisions() { | |
| 66 | let summary = PatchsetSummary( | |
| 67 | id: 1, | |
| 68 | subject: "[PATCH v3] fix the thing", | |
| 69 | version: 3, | |
| 70 | prefix: nil, | |
| 71 | status: .proposed | |
| 72 | ) | |
| 73 | ||
| 74 | #expect(summary.versionLabel == "v3") | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | @MainActor | |
| 79 | struct PatchsetOrderingTests { | |
| 80 | ||
| 81 | private func makePatch(id: Int, index: Int?, count: Int?) -> PatchsetEmail { | |
| 82 | PatchsetEmail( | |
| 83 | id: id, | |
| 84 | subject: "patch \(id)", | |
| 85 | date: nil, | |
| 86 | sender: Entity(canonicalName: "~someone"), | |
| 87 | contentBlocks: [], | |
| 88 | index: index, | |
| 89 | count: count | |
| 90 | ) | |
| 91 | } | |
| 92 | ||
| 93 | @Test | |
| 94 | func patchesAreOrderedBySeriesIndexNotReceiptOrder() { | |
| 95 | let patches = [ | |
| 96 | makePatch(id: 30, index: 3, count: 3), | |
| 97 | makePatch(id: 10, index: 1, count: 3), | |
| 98 | makePatch(id: 20, index: 2, count: 3) | |
| 99 | ] | |
| 100 | ||
| 101 | let ordered = PatchsetDetailViewModel.orderPatches(patches) | |
| 102 | ||
| 103 | #expect(ordered.map(\.index) == [1, 2, 3]) | |
| 104 | } | |
| 105 | ||
| 106 | @Test | |
| 107 | func unindexedPatchesAreKeptAtTheEndRatherThanDropped() { | |
| 108 | let patches = [ | |
| 109 | makePatch(id: 99, index: nil, count: nil), | |
| 110 | makePatch(id: 20, index: 2, count: 2), | |
| 111 | makePatch(id: 10, index: 1, count: 2) | |
| 112 | ] | |
| 113 | ||
| 114 | let ordered = PatchsetDetailViewModel.orderPatches(patches) | |
| 115 | ||
| 116 | #expect(ordered.count == 3) | |
| 117 | #expect(ordered.map(\.index) == [1, 2, nil]) | |
| 118 | } | |
| 119 | ||
| 120 | @Test | |
| 121 | func orderingIsStableForASingleUnindexedPatch() { | |
| 122 | // A lone patch with no [PATCH n/m] prefix is the common one-off case. | |
| 123 | let patches = [makePatch(id: 1, index: nil, count: nil)] | |
| 124 | ||
| 125 | let ordered = PatchsetDetailViewModel.orderPatches(patches) | |
| 126 | ||
| 127 | #expect(ordered.map(\.id) == [1]) | |
| 128 | } | |
| 129 | ||
| 130 | @Test | |
| 131 | func seriesLabelIsHiddenForSinglePatchSeries() { | |
| 132 | let patch = makePatch(id: 1, index: 1, count: 1) | |
| 133 | #expect(patch.seriesLabel == nil) | |
| 134 | } | |
| 135 | ||
| 136 | @Test | |
| 137 | func seriesLabelShowsPositionForMultiPatchSeries() { | |
| 138 | let patch = makePatch(id: 1, index: 2, count: 5) | |
| 139 | #expect(patch.seriesLabel == "2/5") | |
| 140 | } | |
| 141 | } | |