krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.6.1: 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}
28
29@Observable
30@MainActor
31final class MailingListDetailViewModel {
32 private(set) var threads: [InboxThreadSummary] = []
33 private(set) var isLoading = false
34 var error: String?
35 var searchText = ""
36
37 private let mailingList: InboxMailingListReference
38 private let client: SRHTClient
39
40 private static let listThreadsQuery = """
41 query projectMailingListThreads($rid: ID!) {
42 list(rid: $rid) {
43 threads {
44 results {
45 updated
46 subject
47 replies
48 sender { canonicalName }
49 root {
50 id
51 messageID
52 patch { subject }
53 }
54 }
55 }
56 }
57 }
58 """
59
60 init(mailingList: InboxMailingListReference, client: SRHTClient) {
61 self.mailingList = mailingList
62 self.client = client
63 }
64
65 var filteredThreads: [InboxThreadSummary] {
66 Self.filterThreads(threads, matching: searchText)
67 }
68
69 func loadThreads() async {
70 guard !isLoading else { return }
71 isLoading = true
72 error = nil
73 defer { isLoading = false }
74
75 do {
76 let response = try await client.execute(
77 service: .lists,
78 query: Self.listThreadsQuery,
79 variables: ["rid": mailingList.rid],
80 responseType: ProjectMailingListThreadsResponse.self
81 )
82
83 threads = deduplicateThreads(
84 response.list.threads.results.map(makeSummary(from:))
85 )
86 } catch {
87 self.error = "Failed to load mailing list"
88 }
89 }
90
91 func markThreadRead(_ thread: InboxThreadSummary) {
92 let viewedAt = max(Date(), thread.lastActivityAt)
93 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
94 updateThread(thread, isUnread: false)
95 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
96 }
97
98 func markThreadUnread(_ thread: InboxThreadSummary) {
99 InboxReadStateStore.markUnread(for: thread.id)
100 updateThread(thread, isUnread: true)
101 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
102 }
103
104 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
105 let normalizedSubject = thread.subject
106 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
107 .trimmingCharacters(in: .whitespacesAndNewlines)
108 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
109 .lowercased()
110 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
111
112 return InboxThreadSummary(
113 rootEmailID: thread.root.id,
114 rootMessageID: thread.root.messageID,
115 threadRootEmailIDs: [thread.root.id],
116 threadRootMessageIDs: [thread.root.messageID],
117 listID: 0,
118 listRID: mailingList.rid,
119 listName: mailingList.name,
120 listOwner: mailingList.owner,
121 subject: thread.subject,
122 latestSender: thread.sender,
123 lastActivityAt: thread.updated,
124 messageCount: thread.replies + 1,
125 repo: nil,
126 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
127 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
128 )
129 }
130
131 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
132 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
133 let current = threads[index]
134 threads[index] = InboxThreadSummary(
135 rootEmailID: current.rootEmailID,
136 rootMessageID: current.rootMessageID,
137 threadRootEmailIDs: current.threadRootEmailIDs,
138 threadRootMessageIDs: current.threadRootMessageIDs,
139 listID: current.listID,
140 listRID: current.listRID,
141 listName: current.listName,
142 listOwner: current.listOwner,
143 subject: current.subject,
144 latestSender: current.latestSender,
145 lastActivityAt: current.lastActivityAt,
146 messageCount: current.messageCount,
147 repo: current.repo,
148 containsPatch: current.containsPatch,
149 isUnread: isUnread
150 )
151 }
152
153 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
154 var grouped: [String: InboxThreadSummary] = [:]
155
156 for thread in threads {
157 guard let existing = grouped[thread.threadGroupingKey] else {
158 grouped[thread.threadGroupingKey] = thread
159 continue
160 }
161
162 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
163 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
164 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
165 let mergedMessageCount = max(
166 existing.messageCount ?? existing.threadRootMessageIDs.count,
167 thread.messageCount ?? thread.threadRootMessageIDs.count,
168 mergedRootMessageIDs.count
169 )
170
171 grouped[thread.threadGroupingKey] = InboxThreadSummary(
172 rootEmailID: latest.rootEmailID,
173 rootMessageID: latest.rootMessageID,
174 threadRootEmailIDs: mergedRootEmailIDs,
175 threadRootMessageIDs: mergedRootMessageIDs,
176 listID: latest.listID,
177 listRID: latest.listRID,
178 listName: latest.listName,
179 listOwner: latest.listOwner,
180 subject: latest.subject,
181 latestSender: latest.latestSender,
182 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
183 messageCount: mergedMessageCount,
184 repo: latest.repo ?? existing.repo,
185 containsPatch: latest.containsPatch || existing.containsPatch,
186 isUnread: latest.isUnread || existing.isUnread
187 )
188 }
189
190 return grouped.values.sorted { lhs, rhs in
191 if lhs.lastActivityAt == rhs.lastActivityAt {
192 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
193 }
194 return lhs.lastActivityAt > rhs.lastActivityAt
195 }
196 }
197
198 nonisolated static func filterThreads(
199 _ threads: [InboxThreadSummary],
200 matching query: String
201 ) -> [InboxThreadSummary] {
202 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
203 guard !q.isEmpty else { return threads }
204 return threads.filter {
205 normalizedSubject(from: $0.subject).contains(q) ||
206 $0.latestSender.canonicalName.lowercased().contains(q)
207 }
208 }
209
210 private nonisolated static func normalizedSubject(from subject: String) -> String {
211 subject
212 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
213 .trimmingCharacters(in: .whitespacesAndNewlines)
214 .replacingOccurrences(
215 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
216 with: "",
217 options: [.regularExpression, .caseInsensitive]
218 )
219 .lowercased()
220 }
221}
222
223struct MailingListDetailView: View {
224 let mailingList: InboxMailingListReference
225
226 @Environment(AppState.self) private var appState
227 @State private var viewModel: MailingListDetailViewModel?
228
229 var body: some View {
230 Group {
231 if let viewModel {
232 content(viewModel)
233 } else {
234 SRHTLoadingStateView(message: "Loading mailing list…")
235 }
236 }
237 .navigationTitle(mailingList.name)
238 .navigationBarTitleDisplayMode(.inline)
239 .task {
240 if viewModel == nil {
241 let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
242 self.viewModel = viewModel
243 await viewModel.loadThreads()
244 }
245 }
246 .onAppear {
247 guard let viewModel else { return }
248 Task {
249 await viewModel.loadThreads()
250 }
251 }
252 }
253
254 @ViewBuilder
255 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
256 @Bindable var vm = viewModel
257
258 List {
259 ForEach(viewModel.filteredThreads) { thread in
260 NavigationLink(value: MoreRoute.thread(thread)) {
261 InboxThreadRow(thread: thread)
262 }
263 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
264 Button {
265 withAnimation(.easeInOut(duration: 0.2)) {
266 if thread.isUnread {
267 viewModel.markThreadRead(thread)
268 } else {
269 viewModel.markThreadUnread(thread)
270 }
271 }
272 } label: {
273 Label(
274 thread.isUnread ? "Mark as Read" : "Mark as Unread",
275 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
276 )
277 }
278 .tint(thread.isUnread ? .blue : .gray)
279 }
280 }
281 }
282 .listStyle(.plain)
283 .searchable(
284 text: $vm.searchText,
285 placement: .navigationBarDrawer(displayMode: .always),
286 prompt: "Search messages"
287 )
288 .overlay {
289 if viewModel.isLoading, viewModel.threads.isEmpty {
290 SRHTLoadingStateView(message: "Loading mailing list…")
291 } else if let error = viewModel.error, viewModel.threads.isEmpty {
292 SRHTErrorStateView(
293 title: "Couldn't Load Mailing List",
294 message: error,
295 retryAction: { await viewModel.loadThreads() }
296 )
297 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
298 ContentUnavailableView.search(text: viewModel.searchText)
299 } else if viewModel.threads.isEmpty {
300 ContentUnavailableView(
301 "No Threads",
302 systemImage: "tray",
303 description: Text("This mailing list does not have any recent threads.")
304 )
305 }
306 }
307 .refreshable {
308 await viewModel.loadThreads()
309 }
310 .srhtErrorBanner(error: $vm.error)
311 }
312}
313
314struct ProjectMailingListView: View {
315 let mailingList: Project.MailingList
316
317 var body: some View {
318 MailingListDetailView(mailingList: mailingList.inboxReference)
319 }
320}