krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.12.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        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 {
261                    ThreadDetailView(
262                        thread: thread,
263                        onViewed: {
264                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
265                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
266                        },
267                        onMarkRead: {
268                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
269                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
270                        },
271                        onMarkUnread: {
272                            InboxReadStateStore.markUnread(for: thread.id)
273                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
274                        }
275                    )
276                } label: {
277                    InboxThreadRow(thread: thread)
278                }
279                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
280                    Button {
281                        withAnimation(.easeInOut(duration: 0.2)) {
282                            if thread.isUnread {
283                                viewModel.markThreadRead(thread)
284                            } else {
285                                viewModel.markThreadUnread(thread)
286                            }
287                        }
288                    } label: {
289                        Label(
290                            thread.isUnread ? "Mark as Read" : "Mark as Unread",
291                            systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
292                        )
293                    }
294                    .tint(thread.isUnread ? .blue : .gray)
295                }
296            }
297        }
298        .listStyle(.plain)
299        .searchable(
300            text: $vm.searchText,
301            placement: .navigationBarDrawer(displayMode: .always),
302            prompt: "Search messages"
303        )
304        .overlay {
305            if viewModel.isLoading, viewModel.threads.isEmpty {
306                SRHTLoadingStateView(message: "Loading mailing list…")
307            } else if let error = viewModel.error, viewModel.threads.isEmpty {
308                SRHTErrorStateView(
309                    title: "Couldn't Load Mailing List",
310                    message: error,
311                    retryAction: { await viewModel.loadThreads() }
312                )
313            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
314                ContentUnavailableView.search(text: viewModel.searchText)
315            } else if viewModel.threads.isEmpty {
316                ContentUnavailableView(
317                    "No Threads",
318                    systemImage: "tray",
319                    description: Text("This mailing list does not have any recent threads.")
320                )
321            }
322        }
323        .refreshable {
324            await viewModel.loadThreads()
325        }
326        .srhtErrorBanner(error: $vm.error)
327    }
328}
329
330struct ProjectMailingListView: View {
331    let mailingList: Project.MailingList
332
333    var body: some View {
334        MailingListDetailView(mailingList: mailingList.inboxReference)
335    }
336}