krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.7.0: Hutch/Views/Projects/ProjectMailingListView.swift · raw
1import SwiftUI
2
3private struct ProjectMailingListThreadsResponse: Decodable, Sendable {
4 let list: ProjectMailingListThreads
5}
6
7private struct ProjectMailingListThreads: Decodable, Sendable {
8 let threads: ProjectMailingListThreadPage
9}
10
11private struct ProjectMailingListThreadPage: Decodable, Sendable {
12 let results: [ProjectMailingListThreadPayload]
13}
14
15private struct ProjectMailingListThreadPayload: Decodable, Sendable {
16 let updated: Date
17 let subject: String
18 let replies: Int
19 let sender: Entity
20 let root: ProjectMailingListRootPayload
21}
22
23private struct ProjectMailingListRootPayload: Decodable, Sendable {
24 let id: Int
25 let messageID: String
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
32private struct PatchsetSummaryPayload: Decodable, Sendable {
33 let id: Int
34 let subject: String
35 let version: Int
36 let prefix: String?
37 let status: PatchsetStatus
38}
39
40@Observable
41@MainActor
42final class MailingListDetailViewModel {
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] = []
46 private(set) var isLoading = false
47 var error: String?
48 var searchText = ""
49
50 private let mailingList: InboxMailingListReference
51 private let client: SRHTClient
52 private let defaults: UserDefaults
53 private let accountID: String
54
55 private static let listThreadsQuery = """
56 query projectMailingListThreads($rid: ID!) {
57 list(rid: $rid) {
58 threads {
59 results {
60 updated
61 subject
62 replies
63 sender { canonicalName }
64 root {
65 id
66 messageID
67 patch { subject }
68 patchset {
69 id
70 subject
71 version
72 prefix
73 status
74 }
75 }
76 }
77 }
78 }
79 }
80 """
81
82 init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
83 self.mailingList = mailingList
84 self.client = client
85 self.defaults = defaults
86 self.accountID = accountID
87 }
88
89 var filteredThreads: [InboxThreadSummary] {
90 Self.filterThreads(threads, matching: searchText)
91 }
92
93 func loadThreads() async {
94 guard !isLoading else { return }
95 isLoading = true
96 error = nil
97 defer { isLoading = false }
98
99 do {
100 let response = try await client.execute(
101 service: .lists,
102 query: Self.listThreadsQuery,
103 variables: ["rid": mailingList.rid],
104 responseType: ProjectMailingListThreadsResponse.self
105 )
106
107 threads = deduplicateThreads(
108 response.list.threads.results.map(makeSummary(from:))
109 )
110 patchsets = Self.patchsets(from: response.list.threads.results)
111 } catch {
112 self.error = "Failed to load mailing list"
113 }
114 }
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
150 func markThreadRead(_ thread: InboxThreadSummary) {
151 let viewedAt = max(Date(), thread.lastActivityAt)
152 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
153 updateThread(thread, isUnread: false)
154 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
155 }
156
157 func markThreadUnread(_ thread: InboxThreadSummary) {
158 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
159 updateThread(thread, isUnread: true)
160 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
161 }
162
163 func markAllThreadsRead() {
164 let unreadThreads = threads.filter(\.isUnread)
165 guard !unreadThreads.isEmpty else { return }
166
167 let viewedAt = Date()
168 for thread in unreadThreads {
169 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
170 }
171
172 threads = threads.map { thread in
173 guard thread.isUnread else { return thread }
174 return InboxThreadSummary(
175 rootEmailID: thread.rootEmailID,
176 rootMessageID: thread.rootMessageID,
177 threadRootEmailIDs: thread.threadRootEmailIDs,
178 threadRootMessageIDs: thread.threadRootMessageIDs,
179 listID: thread.listID,
180 listRID: thread.listRID,
181 listName: thread.listName,
182 listOwner: thread.listOwner,
183 subject: thread.subject,
184 latestSender: thread.latestSender,
185 lastActivityAt: thread.lastActivityAt,
186 messageCount: thread.messageCount,
187 repo: thread.repo,
188 containsPatch: thread.containsPatch,
189 isUnread: false
190 )
191 }
192
193 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
194 }
195
196 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
197 let normalizedSubject = thread.subject
198 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
199 .trimmingCharacters(in: .whitespacesAndNewlines)
200 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
201 .lowercased()
202 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
203
204 return InboxThreadSummary(
205 rootEmailID: thread.root.id,
206 rootMessageID: thread.root.messageID,
207 threadRootEmailIDs: [thread.root.id],
208 threadRootMessageIDs: [thread.root.messageID],
209 listID: 0,
210 listRID: mailingList.rid,
211 listName: mailingList.name,
212 listOwner: mailingList.owner,
213 subject: thread.subject,
214 latestSender: thread.sender,
215 lastActivityAt: thread.updated,
216 messageCount: thread.replies + 1,
217 repo: nil,
218 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
219 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
220 )
221 }
222
223 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
224 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
225 let current = threads[index]
226 threads[index] = InboxThreadSummary(
227 rootEmailID: current.rootEmailID,
228 rootMessageID: current.rootMessageID,
229 threadRootEmailIDs: current.threadRootEmailIDs,
230 threadRootMessageIDs: current.threadRootMessageIDs,
231 listID: current.listID,
232 listRID: current.listRID,
233 listName: current.listName,
234 listOwner: current.listOwner,
235 subject: current.subject,
236 latestSender: current.latestSender,
237 lastActivityAt: current.lastActivityAt,
238 messageCount: current.messageCount,
239 repo: current.repo,
240 containsPatch: current.containsPatch,
241 isUnread: isUnread
242 )
243 }
244
245 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
246 var grouped: [String: InboxThreadSummary] = [:]
247
248 for thread in threads {
249 guard let existing = grouped[thread.threadGroupingKey] else {
250 grouped[thread.threadGroupingKey] = thread
251 continue
252 }
253
254 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
255 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
256 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
257 let mergedMessageCount = max(
258 existing.messageCount ?? existing.threadRootMessageIDs.count,
259 thread.messageCount ?? thread.threadRootMessageIDs.count,
260 mergedRootMessageIDs.count
261 )
262
263 grouped[thread.threadGroupingKey] = InboxThreadSummary(
264 rootEmailID: latest.rootEmailID,
265 rootMessageID: latest.rootMessageID,
266 threadRootEmailIDs: mergedRootEmailIDs,
267 threadRootMessageIDs: mergedRootMessageIDs,
268 listID: latest.listID,
269 listRID: latest.listRID,
270 listName: latest.listName,
271 listOwner: latest.listOwner,
272 subject: latest.subject,
273 latestSender: latest.latestSender,
274 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
275 messageCount: mergedMessageCount,
276 repo: latest.repo ?? existing.repo,
277 containsPatch: latest.containsPatch || existing.containsPatch,
278 isUnread: latest.isUnread || existing.isUnread
279 )
280 }
281
282 return grouped.values.sorted { lhs, rhs in
283 if lhs.lastActivityAt == rhs.lastActivityAt {
284 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
285 }
286 return lhs.lastActivityAt > rhs.lastActivityAt
287 }
288 }
289
290 nonisolated static func filterThreads(
291 _ threads: [InboxThreadSummary],
292 matching query: String
293 ) -> [InboxThreadSummary] {
294 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
295 guard !q.isEmpty else { return threads }
296 return threads.filter {
297 normalizedSubject(from: $0.subject).contains(q) ||
298 $0.latestSender.canonicalName.lowercased().contains(q)
299 }
300 }
301
302 private nonisolated static func normalizedSubject(from subject: String) -> String {
303 subject
304 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
305 .trimmingCharacters(in: .whitespacesAndNewlines)
306 .replacingOccurrences(
307 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
308 with: "",
309 options: [.regularExpression, .caseInsensitive]
310 )
311 .lowercased()
312 }
313}
314
315enum 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
327struct MailingListDetailView: View {
328 let mailingList: InboxMailingListReference
329
330 @Environment(AppState.self) private var appState
331 @State private var viewModel: MailingListDetailViewModel?
332 @State private var pinChangeCount = 0
333 @State private var scope: MailingListScope = .threads
334
335 private var currentUserKey: String? {
336 appState.currentUser?.canonicalName
337 }
338
339 private var isPinnedToHome: Bool {
340 _ = pinChangeCount
341 guard let currentUserKey else { return false }
342 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
343 }
344
345 private var hasUnreadThreads: Bool {
346 viewModel?.threads.contains(where: \.isUnread) == true
347 }
348
349 var body: some View {
350 Group {
351 if let viewModel {
352 content(viewModel)
353 } else {
354 SRHTLoadingStateView(message: "Loading mailing list…")
355 }
356 }
357 .navigationTitle(mailingList.name)
358 .navigationBarTitleDisplayMode(.inline)
359 .toolbar {
360 ToolbarItem(placement: .topBarTrailing) {
361 Button("Mark All Read") {
362 viewModel?.markAllThreadsRead()
363 }
364 .disabled(hasUnreadThreads == false)
365 }
366 if currentUserKey != nil {
367 ToolbarItem(placement: .topBarTrailing) {
368 Button {
369 togglePinnedState()
370 } label: {
371 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
372 }
373 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
374 }
375 }
376 }
377 .task {
378 if viewModel == nil {
379 let viewModel = MailingListDetailViewModel(
380 mailingList: mailingList,
381 client: appState.client,
382 defaults: appState.accountDefaults,
383 accountID: appState.activeAccountID
384 )
385 self.viewModel = viewModel
386 await viewModel.loadThreads()
387 }
388 }
389 .onAppear {
390 guard let viewModel else { return }
391 Task {
392 await viewModel.loadThreads()
393 }
394 }
395 }
396
397 private func togglePinnedState() {
398 guard let currentUserKey else { return }
399 HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
400 pinChangeCount += 1
401 }
402
403 @ViewBuilder
404 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
405 @Bindable var vm = viewModel
406
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 // Pushed directly rather than by value: this view is also shown
424 // from a project, whose stack declares no MoreRoute destination.
425 NavigationLink {
426 PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
427 } label: {
428 PatchsetRow(patchset: patchset)
429 }
430 .themedRow()
431 }
432 } else {
433 ForEach(viewModel.filteredThreads) { thread in
434 NavigationLink {
435 ThreadDetailView(
436 thread: thread,
437 onViewed: {
438 viewModel.markThreadRead(thread)
439 },
440 onMarkRead: {
441 viewModel.markThreadRead(thread)
442 },
443 onMarkUnread: {
444 viewModel.markThreadUnread(thread)
445 }
446 )
447 } label: {
448 InboxThreadRow(thread: thread)
449 }
450 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
451 Button {
452 withAnimation(.easeInOut(duration: 0.2)) {
453 if thread.isUnread {
454 viewModel.markThreadRead(thread)
455 } else {
456 viewModel.markThreadUnread(thread)
457 }
458 }
459 } label: {
460 Label(
461 thread.isUnread ? "Mark as Read" : "Mark as Unread",
462 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
463 )
464 }
465 .tint(thread.isUnread ? .blue : .gray)
466 }
467 }
468 .themedRow()
469 }
470 }
471 .themedList()
472 .listStyle(.plain)
473 .searchable(
474 text: $vm.searchText,
475 placement: .navigationBarDrawer(displayMode: .always),
476 prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
477 )
478 .overlay {
479 if viewModel.isLoading, viewModel.threads.isEmpty {
480 SRHTLoadingStateView(message: "Loading mailing list…")
481 } else if let error = viewModel.error, viewModel.threads.isEmpty {
482 SRHTErrorStateView(
483 title: "Couldn't Load Mailing List",
484 message: error,
485 retryAction: { await viewModel.loadThreads() }
486 )
487 } else if showingPatches(viewModel) {
488 if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
489 ContentUnavailableView.search(text: viewModel.searchText)
490 }
491 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
492 ContentUnavailableView.search(text: viewModel.searchText)
493 } else if viewModel.threads.isEmpty {
494 ContentUnavailableView(
495 "No Threads",
496 systemImage: "tray",
497 description: Text("This mailing list does not have any recent threads.")
498 )
499 }
500 }
501 .refreshable {
502 await viewModel.loadThreads()
503 }
504 .srhtErrorBanner(error: $vm.error)
505 }
506
507 private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
508 scope == .patches && !viewModel.patchsets.isEmpty
509 }
510}
511
512struct PatchsetRow: View {
513 let patchset: PatchsetSummary
514
515 var body: some View {
516 VStack(alignment: .leading, spacing: 6) {
517 Text(patchset.subject)
518 .font(.subheadline.weight(.medium))
519 .lineLimit(2)
520
521 HStack(spacing: 8) {
522 PatchsetStatusBadge(status: patchset.status)
523 if let versionLabel = patchset.versionLabel {
524 Text(versionLabel)
525 .font(.caption.weight(.medium))
526 .foregroundStyle(.secondary)
527 }
528 }
529 }
530 .padding(.vertical, 2)
531 .accessibilityElement(children: .combine)
532 .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
533 }
534}
535
536struct ProjectMailingListView: View {
537 let mailingList: Project.MailingList
538
539 var body: some View {
540 MailingListDetailView(mailingList: mailingList.inboxReference)
541 }
542}