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