krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.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 let activity = await MailingListActivityLoader.load(
108 client: client,
109 listRID: mailingList.rid,
110 since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast
111 )
112
113 threads = deduplicateThreads(
114 response.list.threads.results.map { makeSummary(from: $0, activity: activity) }
115 )
116 patchsets = Self.patchsets(from: response.list.threads.results)
117 } catch {
118 self.error = "Failed to load mailing list"
119 }
120 }
121
122 var filteredPatchsets: [PatchsetSummary] {
123 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
124 guard !query.isEmpty else { return patchsets }
125 return patchsets.filter { $0.subject.lowercased().contains(query) }
126 }
127
128 /// Collects the patchsets opened by these threads, newest first.
129 ///
130 /// A revised series arrives as its own thread, so the same subject can appear
131 /// at several versions; they are kept as distinct patchsets and the version
132 /// chain is shown in the detail view.
133 private nonisolated static func patchsets(
134 from threads: [ProjectMailingListThreadPayload]
135 ) -> [PatchsetSummary] {
136 var seenIDs = Set<Int>()
137 var results: [PatchsetSummary] = []
138
139 for thread in threads {
140 guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
141 seenIDs.insert(payload.id)
142 results.append(
143 PatchsetSummary(
144 id: payload.id,
145 subject: payload.subject,
146 version: payload.version,
147 prefix: payload.prefix,
148 status: payload.status
149 )
150 )
151 }
152
153 return results
154 }
155
156 func markThreadRead(_ thread: InboxThreadSummary) {
157 let viewedAt = max(Date(), thread.lastActivityAt)
158 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
159 updateThread(thread, isUnread: false)
160 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
161 }
162
163 func markThreadUnread(_ thread: InboxThreadSummary) {
164 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
165 updateThread(thread, isUnread: true)
166 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
167 }
168
169 func markAllThreadsRead() {
170 let unreadThreads = threads.filter(\.isUnread)
171 guard !unreadThreads.isEmpty else { return }
172
173 let viewedAt = Date()
174 for thread in unreadThreads {
175 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
176 }
177
178 threads = threads.map { thread in
179 guard thread.isUnread else { return thread }
180 return InboxThreadSummary(
181 rootEmailID: thread.rootEmailID,
182 rootMessageID: thread.rootMessageID,
183 threadRootEmailIDs: thread.threadRootEmailIDs,
184 threadRootMessageIDs: thread.threadRootMessageIDs,
185 listID: thread.listID,
186 listRID: thread.listRID,
187 listName: thread.listName,
188 listOwner: thread.listOwner,
189 subject: thread.subject,
190 latestSender: thread.latestSender,
191 lastActivityAt: thread.lastActivityAt,
192 messageCount: thread.messageCount,
193 repo: thread.repo,
194 containsPatch: thread.containsPatch,
195 isUnread: false
196 )
197 }
198
199 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
200 }
201
202 private func makeSummary(
203 from thread: ProjectMailingListThreadPayload,
204 activity: MailingListActivity
205 ) -> InboxThreadSummary {
206 let normalizedSubject = thread.subject
207 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
208 .trimmingCharacters(in: .whitespacesAndNewlines)
209 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
210 .lowercased()
211 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
212
213 // thread.updated is the root email's insert time and never advances when a
214 // reply lands, so activity has to come from the list's mail feed.
215 let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated)
216
217 return InboxThreadSummary(
218 rootEmailID: thread.root.id,
219 rootMessageID: thread.root.messageID,
220 threadRootEmailIDs: [thread.root.id],
221 threadRootMessageIDs: [thread.root.messageID],
222 listID: 0,
223 listRID: mailingList.rid,
224 listName: mailingList.name,
225 listOwner: mailingList.owner,
226 subject: thread.subject,
227 latestSender: thread.sender,
228 lastActivityAt: lastActivityAt,
229 messageCount: thread.replies + 1,
230 repo: nil,
231 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
232 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivityAt, defaults: defaults)
233 )
234 }
235
236 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
237 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
238 let current = threads[index]
239 threads[index] = InboxThreadSummary(
240 rootEmailID: current.rootEmailID,
241 rootMessageID: current.rootMessageID,
242 threadRootEmailIDs: current.threadRootEmailIDs,
243 threadRootMessageIDs: current.threadRootMessageIDs,
244 listID: current.listID,
245 listRID: current.listRID,
246 listName: current.listName,
247 listOwner: current.listOwner,
248 subject: current.subject,
249 latestSender: current.latestSender,
250 lastActivityAt: current.lastActivityAt,
251 messageCount: current.messageCount,
252 repo: current.repo,
253 containsPatch: current.containsPatch,
254 isUnread: isUnread
255 )
256 }
257
258 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
259 var grouped: [String: InboxThreadSummary] = [:]
260
261 for thread in threads {
262 guard let existing = grouped[thread.threadGroupingKey] else {
263 grouped[thread.threadGroupingKey] = thread
264 continue
265 }
266
267 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
268 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
269 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
270 let mergedMessageCount = max(
271 existing.messageCount ?? existing.threadRootMessageIDs.count,
272 thread.messageCount ?? thread.threadRootMessageIDs.count,
273 mergedRootMessageIDs.count
274 )
275
276 grouped[thread.threadGroupingKey] = InboxThreadSummary(
277 rootEmailID: latest.rootEmailID,
278 rootMessageID: latest.rootMessageID,
279 threadRootEmailIDs: mergedRootEmailIDs,
280 threadRootMessageIDs: mergedRootMessageIDs,
281 listID: latest.listID,
282 listRID: latest.listRID,
283 listName: latest.listName,
284 listOwner: latest.listOwner,
285 subject: latest.subject,
286 latestSender: latest.latestSender,
287 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
288 messageCount: mergedMessageCount,
289 repo: latest.repo ?? existing.repo,
290 containsPatch: latest.containsPatch || existing.containsPatch,
291 isUnread: latest.isUnread || existing.isUnread
292 )
293 }
294
295 return grouped.values.sorted { lhs, rhs in
296 if lhs.lastActivityAt == rhs.lastActivityAt {
297 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
298 }
299 return lhs.lastActivityAt > rhs.lastActivityAt
300 }
301 }
302
303 nonisolated static func filterThreads(
304 _ threads: [InboxThreadSummary],
305 matching query: String
306 ) -> [InboxThreadSummary] {
307 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
308 guard !q.isEmpty else { return threads }
309 return threads.filter {
310 normalizedSubject(from: $0.subject).contains(q) ||
311 $0.latestSender.canonicalName.lowercased().contains(q)
312 }
313 }
314
315 private nonisolated static func normalizedSubject(from subject: String) -> String {
316 subject
317 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
318 .trimmingCharacters(in: .whitespacesAndNewlines)
319 .replacingOccurrences(
320 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
321 with: "",
322 options: [.regularExpression, .caseInsensitive]
323 )
324 .lowercased()
325 }
326}
327
328enum MailingListScope: String, CaseIterable, Hashable {
329 case threads
330 case patches
331
332 var displayName: String {
333 switch self {
334 case .threads: "Threads"
335 case .patches: "Patches"
336 }
337 }
338}
339
340struct MailingListDetailView: View {
341 let mailingList: InboxMailingListReference
342
343 @Environment(AppState.self) private var appState
344 @State private var viewModel: MailingListDetailViewModel?
345 @State private var pinChangeCount = 0
346 @State private var scope: MailingListScope = .threads
347
348 private var currentUserKey: String? {
349 appState.currentUser?.canonicalName
350 }
351
352 private var isPinnedToHome: Bool {
353 _ = pinChangeCount
354 guard let currentUserKey else { return false }
355 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
356 }
357
358 private var hasUnreadThreads: Bool {
359 viewModel?.threads.contains(where: \.isUnread) == true
360 }
361
362 var body: some View {
363 Group {
364 if let viewModel {
365 content(viewModel)
366 } else {
367 SRHTLoadingStateView(message: "Loading mailing list…")
368 }
369 }
370 .navigationTitle(mailingList.name)
371 .navigationBarTitleDisplayMode(.inline)
372 .toolbar {
373 ToolbarItem(placement: .topBarTrailing) {
374 Button("Mark All Read") {
375 viewModel?.markAllThreadsRead()
376 }
377 .disabled(hasUnreadThreads == false)
378 }
379 if currentUserKey != nil {
380 ToolbarItem(placement: .topBarTrailing) {
381 Button {
382 togglePinnedState()
383 } label: {
384 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
385 }
386 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
387 }
388 }
389 }
390 .task {
391 if viewModel == nil {
392 let viewModel = MailingListDetailViewModel(
393 mailingList: mailingList,
394 client: appState.client,
395 defaults: appState.accountDefaults,
396 accountID: appState.activeAccountID
397 )
398 self.viewModel = viewModel
399 await viewModel.loadThreads()
400 }
401 }
402 .onAppear {
403 guard let viewModel else { return }
404 Task {
405 await viewModel.loadThreads()
406 }
407 }
408 }
409
410 private func togglePinnedState() {
411 guard let currentUserKey else { return }
412 HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
413 pinChangeCount += 1
414 }
415
416 @ViewBuilder
417 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
418 @Bindable var vm = viewModel
419
420 List {
421 // Only offered when the list actually carries patches, so discussion
422 // lists do not grow an empty tab.
423 if !viewModel.patchsets.isEmpty {
424 Picker("Scope", selection: $scope) {
425 ForEach(MailingListScope.allCases, id: \.self) { scope in
426 Text(scope.displayName).tag(scope)
427 }
428 }
429 .pickerStyle(.segmented)
430 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
431 .themedRow()
432 }
433
434 if showingPatches(viewModel) {
435 ForEach(viewModel.filteredPatchsets) { patchset in
436 // Pushed directly rather than by value: this view is also shown
437 // from a project, whose stack declares no MoreRoute destination.
438 NavigationLink {
439 PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
440 } label: {
441 PatchsetRow(patchset: patchset)
442 }
443 .themedRow()
444 }
445 } else {
446 ForEach(viewModel.filteredThreads) { thread in
447 NavigationLink {
448 ThreadDetailView(
449 thread: thread,
450 onViewed: {
451 viewModel.markThreadRead(thread)
452 },
453 onMarkRead: {
454 viewModel.markThreadRead(thread)
455 },
456 onMarkUnread: {
457 viewModel.markThreadUnread(thread)
458 }
459 )
460 } label: {
461 InboxThreadRow(thread: thread)
462 }
463 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
464 Button {
465 withAnimation(.easeInOut(duration: 0.2)) {
466 if thread.isUnread {
467 viewModel.markThreadRead(thread)
468 } else {
469 viewModel.markThreadUnread(thread)
470 }
471 }
472 } label: {
473 Label(
474 thread.isUnread ? "Mark as Read" : "Mark as Unread",
475 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
476 )
477 }
478 .tint(thread.isUnread ? .blue : .gray)
479 }
480 }
481 .themedRow()
482 }
483 }
484 .themedList()
485 .listStyle(.plain)
486 .searchable(
487 text: $vm.searchText,
488 placement: .navigationBarDrawer(displayMode: .always),
489 prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
490 )
491 .overlay {
492 if viewModel.isLoading, viewModel.threads.isEmpty {
493 SRHTLoadingStateView(message: "Loading mailing list…")
494 } else if let error = viewModel.error, viewModel.threads.isEmpty {
495 SRHTErrorStateView(
496 title: "Couldn't Load Mailing List",
497 message: error,
498 retryAction: { await viewModel.loadThreads() }
499 )
500 } else if showingPatches(viewModel) {
501 if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
502 ContentUnavailableView.search(text: viewModel.searchText)
503 }
504 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
505 ContentUnavailableView.search(text: viewModel.searchText)
506 } else if viewModel.threads.isEmpty {
507 ContentUnavailableView(
508 "No Threads",
509 systemImage: "tray",
510 description: Text("This mailing list does not have any recent threads.")
511 )
512 }
513 }
514 .refreshable {
515 await viewModel.loadThreads()
516 }
517 .srhtErrorBanner(error: $vm.error)
518 }
519
520 private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
521 scope == .patches && !viewModel.patchsets.isEmpty
522 }
523}
524
525struct PatchsetRow: View {
526 let patchset: PatchsetSummary
527
528 var body: some View {
529 VStack(alignment: .leading, spacing: 6) {
530 Text(patchset.subject)
531 .font(.subheadline.weight(.medium))
532 .lineLimit(2)
533
534 HStack(spacing: 8) {
535 PatchsetStatusBadge(status: patchset.status)
536 if let versionLabel = patchset.versionLabel {
537 Text(versionLabel)
538 .font(.caption.weight(.medium))
539 .foregroundStyle(.secondary)
540 }
541 }
542 }
543 .padding(.vertical, 2)
544 .accessibilityElement(children: .combine)
545 .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
546 }
547}
548
549struct ProjectMailingListView: View {
550 let mailingList: Project.MailingList
551
552 var body: some View {
553 MailingListDetailView(mailingList: mailingList.inboxReference)
554 }
555}