krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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 private let defaults: UserDefaults
40 private let accountID: String
41
42 private static let listThreadsQuery = """
43 query projectMailingListThreads($rid: ID!) {
44 list(rid: $rid) {
45 threads {
46 results {
47 updated
48 subject
49 replies
50 sender { canonicalName }
51 root {
52 id
53 messageID
54 patch { subject }
55 }
56 }
57 }
58 }
59 }
60 """
61
62 init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
63 self.mailingList = mailingList
64 self.client = client
65 self.defaults = defaults
66 self.accountID = accountID
67 }
68
69 var filteredThreads: [InboxThreadSummary] {
70 Self.filterThreads(threads, matching: searchText)
71 }
72
73 func loadThreads() async {
74 guard !isLoading else { return }
75 isLoading = true
76 error = nil
77 defer { isLoading = false }
78
79 do {
80 let response = try await client.execute(
81 service: .lists,
82 query: Self.listThreadsQuery,
83 variables: ["rid": mailingList.rid],
84 responseType: ProjectMailingListThreadsResponse.self
85 )
86
87 threads = deduplicateThreads(
88 response.list.threads.results.map(makeSummary(from:))
89 )
90 } catch {
91 self.error = "Failed to load mailing list"
92 }
93 }
94
95 func markThreadRead(_ thread: InboxThreadSummary) {
96 let viewedAt = max(Date(), thread.lastActivityAt)
97 InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
98 updateThread(thread, isUnread: false)
99 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
100 }
101
102 func markThreadUnread(_ thread: InboxThreadSummary) {
103 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
104 updateThread(thread, isUnread: true)
105 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
106 }
107
108 func markAllThreadsRead() {
109 let unreadThreads = threads.filter(\.isUnread)
110 guard !unreadThreads.isEmpty else { return }
111
112 let viewedAt = Date()
113 for thread in unreadThreads {
114 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
115 }
116
117 threads = threads.map { thread in
118 guard thread.isUnread else { return thread }
119 return InboxThreadSummary(
120 rootEmailID: thread.rootEmailID,
121 rootMessageID: thread.rootMessageID,
122 threadRootEmailIDs: thread.threadRootEmailIDs,
123 threadRootMessageIDs: thread.threadRootMessageIDs,
124 listID: thread.listID,
125 listRID: thread.listRID,
126 listName: thread.listName,
127 listOwner: thread.listOwner,
128 subject: thread.subject,
129 latestSender: thread.latestSender,
130 lastActivityAt: thread.lastActivityAt,
131 messageCount: thread.messageCount,
132 repo: thread.repo,
133 containsPatch: thread.containsPatch,
134 isUnread: false
135 )
136 }
137
138 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
139 }
140
141 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
142 let normalizedSubject = thread.subject
143 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
144 .trimmingCharacters(in: .whitespacesAndNewlines)
145 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
146 .lowercased()
147 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
148
149 return InboxThreadSummary(
150 rootEmailID: thread.root.id,
151 rootMessageID: thread.root.messageID,
152 threadRootEmailIDs: [thread.root.id],
153 threadRootMessageIDs: [thread.root.messageID],
154 listID: 0,
155 listRID: mailingList.rid,
156 listName: mailingList.name,
157 listOwner: mailingList.owner,
158 subject: thread.subject,
159 latestSender: thread.sender,
160 lastActivityAt: thread.updated,
161 messageCount: thread.replies + 1,
162 repo: nil,
163 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
164 isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
165 )
166 }
167
168 private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
169 guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
170 let current = threads[index]
171 threads[index] = InboxThreadSummary(
172 rootEmailID: current.rootEmailID,
173 rootMessageID: current.rootMessageID,
174 threadRootEmailIDs: current.threadRootEmailIDs,
175 threadRootMessageIDs: current.threadRootMessageIDs,
176 listID: current.listID,
177 listRID: current.listRID,
178 listName: current.listName,
179 listOwner: current.listOwner,
180 subject: current.subject,
181 latestSender: current.latestSender,
182 lastActivityAt: current.lastActivityAt,
183 messageCount: current.messageCount,
184 repo: current.repo,
185 containsPatch: current.containsPatch,
186 isUnread: isUnread
187 )
188 }
189
190 private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
191 var grouped: [String: InboxThreadSummary] = [:]
192
193 for thread in threads {
194 guard let existing = grouped[thread.threadGroupingKey] else {
195 grouped[thread.threadGroupingKey] = thread
196 continue
197 }
198
199 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
200 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
201 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
202 let mergedMessageCount = max(
203 existing.messageCount ?? existing.threadRootMessageIDs.count,
204 thread.messageCount ?? thread.threadRootMessageIDs.count,
205 mergedRootMessageIDs.count
206 )
207
208 grouped[thread.threadGroupingKey] = InboxThreadSummary(
209 rootEmailID: latest.rootEmailID,
210 rootMessageID: latest.rootMessageID,
211 threadRootEmailIDs: mergedRootEmailIDs,
212 threadRootMessageIDs: mergedRootMessageIDs,
213 listID: latest.listID,
214 listRID: latest.listRID,
215 listName: latest.listName,
216 listOwner: latest.listOwner,
217 subject: latest.subject,
218 latestSender: latest.latestSender,
219 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
220 messageCount: mergedMessageCount,
221 repo: latest.repo ?? existing.repo,
222 containsPatch: latest.containsPatch || existing.containsPatch,
223 isUnread: latest.isUnread || existing.isUnread
224 )
225 }
226
227 return grouped.values.sorted { lhs, rhs in
228 if lhs.lastActivityAt == rhs.lastActivityAt {
229 return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
230 }
231 return lhs.lastActivityAt > rhs.lastActivityAt
232 }
233 }
234
235 nonisolated static func filterThreads(
236 _ threads: [InboxThreadSummary],
237 matching query: String
238 ) -> [InboxThreadSummary] {
239 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
240 guard !q.isEmpty else { return threads }
241 return threads.filter {
242 normalizedSubject(from: $0.subject).contains(q) ||
243 $0.latestSender.canonicalName.lowercased().contains(q)
244 }
245 }
246
247 private nonisolated static func normalizedSubject(from subject: String) -> String {
248 subject
249 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
250 .trimmingCharacters(in: .whitespacesAndNewlines)
251 .replacingOccurrences(
252 of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
253 with: "",
254 options: [.regularExpression, .caseInsensitive]
255 )
256 .lowercased()
257 }
258}
259
260struct MailingListDetailView: View {
261 let mailingList: InboxMailingListReference
262
263 @Environment(AppState.self) private var appState
264 @State private var viewModel: MailingListDetailViewModel?
265 @State private var pinChangeCount = 0
266
267 private var currentUserKey: String? {
268 appState.currentUser?.canonicalName
269 }
270
271 private var isPinnedToHome: Bool {
272 _ = pinChangeCount
273 guard let currentUserKey else { return false }
274 return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
275 }
276
277 private var hasUnreadThreads: Bool {
278 viewModel?.threads.contains(where: \.isUnread) == true
279 }
280
281 var body: some View {
282 Group {
283 if let viewModel {
284 content(viewModel)
285 } else {
286 SRHTLoadingStateView(message: "Loading mailing list…")
287 }
288 }
289 .navigationTitle(mailingList.name)
290 .navigationBarTitleDisplayMode(.inline)
291 .toolbar {
292 ToolbarItem(placement: .topBarTrailing) {
293 Button("Mark All Read") {
294 viewModel?.markAllThreadsRead()
295 }
296 .disabled(hasUnreadThreads == false)
297 }
298 if currentUserKey != nil {
299 ToolbarItem(placement: .topBarTrailing) {
300 Button {
301 togglePinnedState()
302 } label: {
303 Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
304 }
305 .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
306 }
307 }
308 }
309 .task {
310 if viewModel == nil {
311 let viewModel = MailingListDetailViewModel(
312 mailingList: mailingList,
313 client: appState.client,
314 defaults: appState.accountDefaults,
315 accountID: appState.activeAccountID
316 )
317 self.viewModel = viewModel
318 await viewModel.loadThreads()
319 }
320 }
321 .onAppear {
322 guard let viewModel else { return }
323 Task {
324 await viewModel.loadThreads()
325 }
326 }
327 }
328
329 private func togglePinnedState() {
330 guard let currentUserKey else { return }
331 HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
332 pinChangeCount += 1
333 }
334
335 @ViewBuilder
336 private func content(_ viewModel: MailingListDetailViewModel) -> some View {
337 @Bindable var vm = viewModel
338
339 List {
340 ForEach(viewModel.filteredThreads) { thread in
341 NavigationLink {
342 ThreadDetailView(
343 thread: thread,
344 onViewed: {
345 viewModel.markThreadRead(thread)
346 },
347 onMarkRead: {
348 viewModel.markThreadRead(thread)
349 },
350 onMarkUnread: {
351 viewModel.markThreadUnread(thread)
352 }
353 )
354 } label: {
355 InboxThreadRow(thread: thread)
356 }
357 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
358 Button {
359 withAnimation(.easeInOut(duration: 0.2)) {
360 if thread.isUnread {
361 viewModel.markThreadRead(thread)
362 } else {
363 viewModel.markThreadUnread(thread)
364 }
365 }
366 } label: {
367 Label(
368 thread.isUnread ? "Mark as Read" : "Mark as Unread",
369 systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
370 )
371 }
372 .tint(thread.isUnread ? .blue : .gray)
373 }
374 }
375 .themedRow()
376 }
377 .themedList()
378 .listStyle(.plain)
379 .searchable(
380 text: $vm.searchText,
381 placement: .navigationBarDrawer(displayMode: .always),
382 prompt: "Search messages"
383 )
384 .overlay {
385 if viewModel.isLoading, viewModel.threads.isEmpty {
386 SRHTLoadingStateView(message: "Loading mailing list…")
387 } else if let error = viewModel.error, viewModel.threads.isEmpty {
388 SRHTErrorStateView(
389 title: "Couldn't Load Mailing List",
390 message: error,
391 retryAction: { await viewModel.loadThreads() }
392 )
393 } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
394 ContentUnavailableView.search(text: viewModel.searchText)
395 } else if viewModel.threads.isEmpty {
396 ContentUnavailableView(
397 "No Threads",
398 systemImage: "tray",
399 description: Text("This mailing list does not have any recent threads.")
400 )
401 }
402 }
403 .refreshable {
404 await viewModel.loadThreads()
405 }
406 .srhtErrorBanner(error: $vm.error)
407 }
408}
409
410struct ProjectMailingListView: View {
411 let mailingList: Project.MailingList
412
413 var body: some View {
414 MailingListDetailView(mailingList: mailingList.inboxReference)
415 }
416}