krz/hutch

an ios client for sourcehut

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

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