summaryrefslogtreecommitdiff
path: root/Hutch/Views/Projects/ProjectMailingListView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Projects/ProjectMailingListView.swift')
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift124
1 files changed, 123 insertions, 1 deletions
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index d466121..3933bf3 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable {
let id: Int
let messageID: String
let patch: InboxPatchPreview?
+ /// Null unless the thread's root email opens a patchset. `MailingList` has no
+ /// patchsets field, so this is the only way to enumerate a list's patchsets.
+ let patchset: PatchsetSummaryPayload?
+}
+
+private struct PatchsetSummaryPayload: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let version: Int
+ let prefix: String?
+ let status: PatchsetStatus
}
@Observable
@MainActor
final class MailingListDetailViewModel {
private(set) var threads: [InboxThreadSummary] = []
+ /// Patchsets on this list, derived from thread roots — see the query below.
+ private(set) var patchsets: [PatchsetSummary] = []
private(set) var isLoading = false
var error: String?
var searchText = ""
@@ -52,6 +65,13 @@ final class MailingListDetailViewModel {
id
messageID
patch { subject }
+ patchset {
+ id
+ subject
+ version
+ prefix
+ status
+ }
}
}
}
@@ -87,11 +107,46 @@ final class MailingListDetailViewModel {
threads = deduplicateThreads(
response.list.threads.results.map(makeSummary(from:))
)
+ patchsets = Self.patchsets(from: response.list.threads.results)
} catch {
self.error = "Failed to load mailing list"
}
}
+ var filteredPatchsets: [PatchsetSummary] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return patchsets }
+ return patchsets.filter { $0.subject.lowercased().contains(query) }
+ }
+
+ /// Collects the patchsets opened by these threads, newest first.
+ ///
+ /// A revised series arrives as its own thread, so the same subject can appear
+ /// at several versions; they are kept as distinct patchsets and the version
+ /// chain is shown in the detail view.
+ private nonisolated static func patchsets(
+ from threads: [ProjectMailingListThreadPayload]
+ ) -> [PatchsetSummary] {
+ var seenIDs = Set<Int>()
+ var results: [PatchsetSummary] = []
+
+ for thread in threads {
+ guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
+ seenIDs.insert(payload.id)
+ results.append(
+ PatchsetSummary(
+ id: payload.id,
+ subject: payload.subject,
+ version: payload.version,
+ prefix: payload.prefix,
+ status: payload.status
+ )
+ )
+ }
+
+ return results
+ }
+
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
@@ -257,12 +312,25 @@ final class MailingListDetailViewModel {
}
}
+enum MailingListScope: String, CaseIterable, Hashable {
+ case threads
+ case patches
+
+ var displayName: String {
+ switch self {
+ case .threads: "Threads"
+ case .patches: "Patches"
+ }
+ }
+}
+
struct MailingListDetailView: View {
let mailingList: InboxMailingListReference
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListDetailViewModel?
@State private var pinChangeCount = 0
+ @State private var scope: MailingListScope = .threads
private var currentUserKey: String? {
appState.currentUser?.canonicalName
@@ -337,6 +405,27 @@ struct MailingListDetailView: View {
@Bindable var vm = viewModel
List {
+ // Only offered when the list actually carries patches, so discussion
+ // lists do not grow an empty tab.
+ if !viewModel.patchsets.isEmpty {
+ Picker("Scope", selection: $scope) {
+ ForEach(MailingListScope.allCases, id: \.self) { scope in
+ Text(scope.displayName).tag(scope)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
+ .themedRow()
+ }
+
+ if showingPatches(viewModel) {
+ ForEach(viewModel.filteredPatchsets) { patchset in
+ NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) {
+ PatchsetRow(patchset: patchset)
+ }
+ .themedRow()
+ }
+ } else {
ForEach(viewModel.filteredThreads) { thread in
NavigationLink {
ThreadDetailView(
@@ -373,13 +462,14 @@ struct MailingListDetailView: View {
}
}
.themedRow()
+ }
}
.themedList()
.listStyle(.plain)
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search messages"
+ prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
)
.overlay {
if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -390,6 +480,10 @@ struct MailingListDetailView: View {
message: error,
retryAction: { await viewModel.loadThreads() }
)
+ } else if showingPatches(viewModel) {
+ if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
+ ContentUnavailableView.search(text: viewModel.searchText)
+ }
} else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
ContentUnavailableView.search(text: viewModel.searchText)
} else if viewModel.threads.isEmpty {
@@ -405,6 +499,34 @@ struct MailingListDetailView: View {
}
.srhtErrorBanner(error: $vm.error)
}
+
+ private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
+ scope == .patches && !viewModel.patchsets.isEmpty
+ }
+}
+
+struct PatchsetRow: View {
+ let patchset: PatchsetSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(patchset.subject)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ PatchsetStatusBadge(status: patchset.status)
+ if let versionLabel = patchset.versionLabel {
+ Text(versionLabel)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
+ }
}
struct ProjectMailingListView: View {