krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.11.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
40private struct ListMetaResponse: Decodable, Sendable {
41 let list: ListMetaPayload?
42}
43
44private struct ListMetaPayload: Decodable, Sendable {
45 let id: Int
46 let owner: Entity
47}
48
49private struct SubscriptionRidsResponse: Decodable, Sendable {
50 let subscriptions: SubscriptionRidsPage
51}
52
53private struct SubscriptionRidsPage: Decodable, Sendable {
54 let results: [SubscriptionRidEntry]
55 let cursor: String?
56}
57
58private struct SubscriptionRidEntry: Decodable, Sendable {
59 let list: SubscriptionRidList?
60}
61
62private struct SubscriptionRidList: Decodable, Sendable {
63 let rid: String
64}
65
66/// Toggle mutations return the subscription (nullable on unsubscribe); only
67/// success matters.
68private struct SubscriptionToggleResponse: Decodable, Sendable {}
69
70@Observable
71@MainActor
72final class MailingListDetailViewModel {
73 private(set) var threads: [InboxThreadSummary] = []
74 /// Patchsets on this list, derived from thread roots — see the query below.
75 private(set) var patchsets: [PatchsetSummary] = []
76 private(set) var isLoading = false
77 var error: String?
78 var searchText = ""
79
80 /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable
81 /// (the toggle stays hidden); `isOwnList` hides it for lists you own.
82 private(set) var listNumericID: Int?
83 private(set) var isSubscribed: Bool?
84 private(set) var isOwnList = false
85 private(set) var isTogglingSubscription = false
86
87 private let mailingList: InboxMailingListReference
88 private let client: SRHTClient
89 private let defaults: UserDefaults
90 private let accountID: String
91
92 private static let listThreadsQuery = """
93 query projectMailingListThreads($rid: ID!) {
94 list(rid: $rid) {
95 threads {
96 results {
97 updated
98 subject
99 replies
100 sender { canonicalName }
101 root {
102 id
103 messageID
104 patch { subject }
105 patchset {
106 id
107 subject
108 version
109 prefix
110 status
111 }
112 }
113 }
114 }
115 }
116 }
117 """
118
119 init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
120 self.mailingList = mailingList
121 self.client = client
122 self.defaults = defaults
123 self.accountID = accountID
124 }
125
126 // MARK: - Subscription
127
128 private static let listMetaQuery = """
129 query listMeta($rid: ID!) {
130 list(rid: $rid) { id owner { canonicalName } }
131 }
132 """
133
134 // `MailingList.subscription` is unreliable (see the API-traps note), so
135 // subscribe state comes from the authoritative `subscriptions` query.
136 private static let subscriptionRidsQuery = """
137 query subscriptionRids($cursor: Cursor) {
138 subscriptions(cursor: $cursor) {
139 results {
140 ... on MailingListSubscription { list { rid } }
141 }
142 cursor
143 }
144 }
145 """
146
147 private static let subscribeMutation = """
148 mutation mailingListSubscribe($id: Int!) {
149 mailingListSubscribe(listID: $id) { id }
150 }
151 """
152
153 private static let unsubscribeMutation = """
154 mutation mailingListUnsubscribe($id: Int!) {
155 mailingListUnsubscribe(listID: $id) { id }
156 }
157 """
158
159 /// Resolves the list's numeric id, whether the viewer owns it, and — for
160 /// lists they don't own — whether they're subscribed.
161 func loadSubscriptionState(currentUserCanonicalName: String?) async {
162 do {
163 let meta = try await client.execute(
164 service: .lists,
165 query: Self.listMetaQuery,
166 variables: ["rid": mailingList.rid],
167 responseType: ListMetaResponse.self
168 )
169 guard let list = meta.list else { return }
170 listNumericID = list.id
171
172 if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName {
173 isOwnList = true
174 return
175 }
176 isSubscribed = try await isSubscribed(toRid: mailingList.rid)
177 } catch {
178 // Leave state unknown; the toggle stays hidden rather than lying.
179 }
180 }
181
182 private func isSubscribed(toRid rid: String) async throws -> Bool {
183 var cursor: String?
184 repeat {
185 let response = try await client.execute(
186 service: .lists,
187 query: Self.subscriptionRidsQuery,
188 variables: cursor.map { ["cursor": $0] },
189 responseType: SubscriptionRidsResponse.self
190 )
191 if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) {
192 return true
193 }
194 cursor = response.subscriptions.cursor
195 } while cursor != nil
196 return false
197 }
198
199 func toggleSubscription() async {
200 guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return }
201 isTogglingSubscription = true
202 defer { isTogglingSubscription = false }
203 do {
204 _ = try await client.execute(
205 service: .lists,
206 query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation,
207 variables: ["id": id],
208 responseType: SubscriptionToggleResponse.self
209 )
210 isSubscribed = !subscribed
211 } catch {
212 self.error = error.userFacingMessage
213 }
214 }
215
216 var filteredThreads: [InboxThreadSummary] {
217 Self.filterThreads(threads, matching: searchText)
218 }
219
220 func loadThreads() async {
221 guard !isLoading else { return }
222 isLoading = true
223 error = nil
224 defer { isLoading = false }
225
226 do {
227 let response = try await client.execute(
228 service: .lists,
229 query: Self.listThreadsQuery,
230 variables: ["rid": mailingList.rid],
231 responseType: ProjectMailingListThreadsResponse.self
232 )
233
234 let activity = await MailingListActivityLoader.load(
235 client: client,
236 listRID: mailingList.rid,
237 since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast
238 )
239
240 threads = deduplicateThreads(
241 response.list.threads.results.map { makeSummary(from: $0, activity: activity) }
242 )
243 patchsets = Self.patchsets(from: response.list.threads.results)
244 } catch {
245 self.error = "Failed to load mailing list"
246 }
247 }
248
249 var filteredPatchsets: [PatchsetSummary] {
250 let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
251 guard !query.isEmpty else { return patchsets }
252 return patchsets.filter { $0.subject.lowercased().contains(query) }
253 }
254
255 /// Collects the patchsets opened by these threads, newest first.
256 ///
257 /// A revised series arrives as its own thread, so the same subject can appear
258 /// at several versions; they are kept as distinct patchsets and the version
259 /// chain is shown in the detail view.
260 private nonisolated static func patchsets(
261 from threads: [ProjectMailingListThreadPayload]
262 ) -> [PatchsetSummary] {
263 var seenIDs = Set<Int>()
264 var results: [PatchsetSummary] = []
265
266 for thread in threads {
267 guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
268 seenIDs.insert(payload.id)
269 results.append(
270 PatchsetSummary(
271 id: payload.id,
272 subject: payload.subject,
273 version: payload.version,
274 prefix: payload.prefix,
275 status: payload.status
276 )
277 )
278 }
279
280 return results
281 }
282
283 func markThreadRead(_ thread: InboxThreadSummary) {
284 let viewedAt = max(Date(), thread.lastActivityAt)
285 InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
286 updateThread(thread, isUnread: false)
287 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
288 }
289
290 func markThreadUnread(_ thread: InboxThreadSummary) {
291 InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
292 updateThread(thread, isUnread: true)
293 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
294 }
295
296 func markAllThreadsRead() {
297 let unreadThreads = threads.filter(\.isUnread)
298 guard !unreadThreads.isEmpty else { return }
299
300 let viewedAt = Date()
301 for thread in unreadThreads {
302 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
303 }
304
305 threads = threads.map { thread in
306 guard thread.isUnread else { return thread }
307 return InboxThreadSummary(
308 rootEmailID: thread.rootEmailID,
309 rootMessageID: thread.rootMessageID,
310 threadRootEmailIDs: thread.threadRootEmailIDs,
311 threadRootMessageIDs: thread.threadRootMessageIDs,
312 listID: thread.listID,
313 listRID: thread.listRID,
314 listName: thread.listName,
315 listOwner: thread.listOwner,
316 subject: thread.subject,
317 latestSender: thread.latestSender,
318 lastActivityAt: thread.lastActivityAt,
319 messageCount: thread.messageCount,
320 repo: thread.repo,
321 containsPatch: thread.containsPatch,
322 isUnread: false
323 )
324 }
325
326 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
327 }
328
329 private func makeSummary(
330 from thread: ProjectMailingListThreadPayload,
331 activity: MailingListActivity
332 ) -> InboxThreadSummary {
333 let normalizedSubject = thread.subject
334 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
335 .trimmingCharacters(in: .whitespacesAndNewlines)
336 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
337 .lowercased()
338 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
339
340 // thread.updated is the root email's insert time and never advances when a
341 // reply lands, so activity has to come from the list's mail feed.
342 let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated)
343
344 return InboxThreadSummary(
345 rootEmailID: thread.root.id,
346 rootMessageID: thread.root.messageID,
347 threadRootEmailIDs: [thread.root.id],
348 threadRootMessageIDs: [thread.root.messageID],
349 listID: 0,
350 listRID: mailingList.rid,
351 listName: mailingList.name,
352 listOwner: mailingList.owner,
353 subject: thread.subject,
354 latestSender: thread.sender,
355 lastActivityAt: lastActivityAt,
356 messageCount: thread.replies + 1,
357 repo: nil,
358 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
359 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivityAt, defaults: defaults)
360 )
361 }
362
363 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
364 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
365 let current = threads[index]
366 threads[index] = InboxThreadSummary(
367 rootEmailID: current.rootEmailID,
368 rootMessageID: current.rootMessageID,
369 threadRootEmailIDs: current.threadRootEmailIDs,
370 threadRootMessageIDs: current.threadRootMessageIDs,
371 listID: current.listID,
372 listRID: current.listRID,
373 listName: current.listName,
374 listOwner: current.listOwner,
375 subject: current.subject,
376 latestSender: current.latestSender,
377 lastActivityAt: current.lastActivityAt,
378 messageCount: current.messageCount,
379 repo: current.repo,
380 containsPatch: current.containsPatch,
381 isUnread: isUnread
382 )
383 }
384
385 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
386 var grouped: [String: InboxThreadSummary] = [:]
387
388 for thread in threads {
389 guard let existing = grouped[thread.threadGroupingKey] else {
390 grouped[thread.threadGroupingKey] = thread
391 continue
392 }
393
394 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
395 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
396 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
397 let mergedMessageCount = max(
398 existing.messageCount ?? existing.threadRootMessageIDs.count,
399 thread.messageCount ?? thread.threadRootMessageIDs.count,
400 mergedRootMessageIDs.count
401 )
402
403 grouped[thread.threadGroupingKey] = InboxThreadSummary(
404 rootEmailID: latest.rootEmailID,
405 rootMessageID: latest.rootMessageID,
406 threadRootEmailIDs: mergedRootEmailIDs,
407 threadRootMessageIDs: mergedRootMessageIDs,
408 listID: latest.listID,
409 listRID: latest.listRID,
410 listName: latest.listName,
411 listOwner: latest.listOwner,
412 subject: latest.subject,
413 latestSender: latest.latestSender,
414 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
415 messageCount: mergedMessageCount,
416 repo: latest.repo ?? existing.repo,
417 containsPatch: latest.containsPatch || existing.containsPatch,
418 isUnread: latest.isUnread || existing.isUnread
419 )
420 }
421
422 return grouped.values.sorted { lhs, rhs in
423 if lhs.lastActivityAt == rhs.lastActivityAt {
424 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
425 }
426 return lhs.lastActivityAt > rhs.lastActivityAt
427 }
428 }
429
430 nonisolated static func filterThreads(
431 _ threads: [InboxThreadSummary],
432 matching query: String
433 ) -> [InboxThreadSummary] {
434 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
435 guard !q.isEmpty else { return threads }
436 return threads.filter {
437 normalizedSubject(from: $0.subject).contains(q) ||
438 $0.latestSender.canonicalName.lowercased().contains(q)
439 }
440 }
441
442 private nonisolated static func normalizedSubject(from subject: String) -> String {
443 subject
444 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
445 .trimmingCharacters(in: .whitespacesAndNewlines)
446 .replacingOccurrences(
447 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
448 with: "",
449 options: [.regularExpression, .caseInsensitive]
450 )
451 .lowercased()
452 }
453}
454
455enum MailingListScope: String, CaseIterable, Hashable {
456 case threads
457 case patches
458
459 var displayName: String {
460 switch self {
461 case .threads: "Threads"
462 case .patches: "Patches"
463 }
464 }
465}
466
467struct MailingListDetailView: View {
468 let mailingList: InboxMailingListReference
469
470 @Environment(AppState.self) private var appState
471 @State private var viewModel: MailingListDetailViewModel?
472 @State private var pinChangeCount = 0
473 @State private var scope: MailingListScope = .threads
474
475 private var currentUserKey: String? {
476 appState.currentUser?.canonicalName
477 }
478
479 private var isPinnedToHome: Bool {
480 _ = pinChangeCount
481 guard let currentUserKey else { return false }
482 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
483 }
484
485 private var hasUnreadThreads: Bool {
486 viewModel?.threads.contains(where: \.isUnread) == true
487 }
488
489 var body: some View {
490 Group {
491 if let viewModel {
492 content(viewModel)
493 } else {
494 SRHTLoadingStateView(message: "Loading mailing list…")
495 }
496 }
497 .navigationTitle(mailingList.name)
498 .navigationBarTitleDisplayMode(.inline)
499 .toolbar {
500 ToolbarItem(placement: .topBarTrailing) {
501 Button("Mark All Read") {
502 viewModel?.markAllThreadsRead()
503 }
504 .disabled(hasUnreadThreads == false)
505 }
506 if currentUserKey != nil {
507 ToolbarItem(placement: .topBarTrailing) {
508 Button {
509 togglePinnedState()
510 } label: {
511 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
512 }
513 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
514 }
515 }
516 if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed {
517 ToolbarItem(placement: .topBarTrailing) {
518 Button {
519 Task { await viewModel.toggleSubscription() }
520 } label: {
521 if viewModel.isTogglingSubscription {
522 ProgressView().controlSize(.small)
523 } else {
524 Image(systemName: subscribed ? "bell.fill" : "bell")
525 }
526 }
527 .disabled(viewModel.isTogglingSubscription)
528 .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list")
529 }
530 }
531 }
532 .task {
533 if viewModel == nil {
534 let viewModel = MailingListDetailViewModel(
535 mailingList: mailingList,
536 client: appState.client,
537 defaults: appState.accountDefaults,
538 accountID: appState.activeAccountID
539 )
540 self.viewModel = viewModel
541 await viewModel.loadThreads()
542 await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
543 }
544 }
545 .onAppear {
546 guard let viewModel else { return }
547 Task {
548 await viewModel.loadThreads()
549 }
550 }
551 }
552
553 private func togglePinnedState() {
554 guard let currentUserKey else { return }
555 HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
556 pinChangeCount += 1
557 }
558
559 @ViewBuilder
560 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
561 @Bindable var vm = viewModel
562
563 List {
564 // Only offered when the list actually carries patches, so discussion
565 // lists do not grow an empty tab.
566 if !viewModel.patchsets.isEmpty {
567 Picker("Scope", selection: $scope) {
568 ForEach(MailingListScope.allCases, id: \.self) { scope in
569 Text(scope.displayName).tag(scope)
570 }
571 }
572 .pickerStyle(.segmented)
573 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
574 .themedRow()
575 }
576
577 if showingPatches(viewModel) {
578 ForEach(viewModel.filteredPatchsets) { patchset in
579 // Pushed directly rather than by value: this view is also shown
580 // from a project, whose stack declares no MoreRoute destination.
581 NavigationLink {
582 PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
583 } label: {
584 PatchsetRow(patchset: patchset)
585 }
586 .themedRow()
587 }
588 } else {
589 ForEach(viewModel.filteredThreads) { thread in
590 NavigationLink {
591 ThreadDetailView(
592 thread: thread,
593 onViewed: {
594 viewModel.markThreadRead(thread)
595 },
596 onMarkRead: {
597 viewModel.markThreadRead(thread)
598 },
599 onMarkUnread: {
600 viewModel.markThreadUnread(thread)
601 }
602 )
603 } label: {
604 InboxThreadRow(thread: thread)
605 }
606 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
607 Button {
608 withAnimation(.easeInOut(duration: 0.2)) {
609 if thread.isUnread {
610 viewModel.markThreadRead(thread)
611 } else {
612 viewModel.markThreadUnread(thread)
613 }
614 }
615 } label: {
616 Label(
617 thread.isUnread ? "Mark as Read" : "Mark as Unread",
618 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
619 )
620 }
621 .tint(thread.isUnread ? .blue : .gray)
622 }
623 }
624 .themedRow()
625 }
626 }
627 .themedList()
628 .listStyle(.plain)
629 .searchable(
630 text: $vm.searchText,
631 placement: .navigationBarDrawer(displayMode: .always),
632 prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
633 )
634 .overlay {
635 if viewModel.isLoading, viewModel.threads.isEmpty {
636 SRHTLoadingStateView(message: "Loading mailing list…")
637 } else if let error = viewModel.error, viewModel.threads.isEmpty {
638 SRHTErrorStateView(
639 title: "Couldn't Load Mailing List",
640 message: error,
641 retryAction: { await viewModel.loadThreads() }
642 )
643 } else if showingPatches(viewModel) {
644 if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
645 ContentUnavailableView.search(text: viewModel.searchText)
646 }
647 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
648 ContentUnavailableView.search(text: viewModel.searchText)
649 } else if viewModel.threads.isEmpty {
650 ContentUnavailableView(
651 "No Threads",
652 systemImage: "tray",
653 description: Text("This mailing list does not have any recent threads.")
654 )
655 }
656 }
657 .refreshable {
658 await viewModel.loadThreads()
659 }
660 .srhtErrorBanner(error: $vm.error)
661 }
662
663 private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
664 scope == .patches && !viewModel.patchsets.isEmpty
665 }
666}
667
668struct PatchsetRow: View {
669 let patchset: PatchsetSummary
670
671 var body: some View {
672 VStack(alignment: .leading, spacing: 6) {
673 Text(patchset.subject)
674 .font(.subheadline.weight(.medium))
675 .lineLimit(2)
676
677 HStack(spacing: 8) {
678 PatchsetStatusBadge(status: patchset.status)
679 if let versionLabel = patchset.versionLabel {
680 Text(versionLabel)
681 .font(.caption.weight(.medium))
682 .foregroundStyle(.secondary)
683 }
684 }
685 }
686 .padding(.vertical, 2)
687 .accessibilityElement(children: .combine)
688 .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
689 }
690}
691
692struct ProjectMailingListView: View {
693 let mailingList: Project.MailingList
694
695 var body: some View {
696 MailingListDetailView(mailingList: mailingList.inboxReference)
697 }
698}