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