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