krz/hutch

an ios client for sourcehut

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

v2.3.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    }
 96
 97    func markThreadUnread(_ thread: InboxThreadSummary) {
 98        InboxReadStateStore.markUnread(for: thread.id)
 99        updateThread(thread, isUnread: true)
100    }
101
102    private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
103        let normalizedSubject = thread.subject
104            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
105            .trimmingCharacters(in: .whitespacesAndNewlines)
106            .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
107            .lowercased()
108        let threadID = "\(mailingList.rid)#\(normalizedSubject)"
109
110        return InboxThreadSummary(
111            rootEmailID: thread.root.id,
112            rootMessageID: thread.root.messageID,
113            threadRootEmailIDs: [thread.root.id],
114            threadRootMessageIDs: [thread.root.messageID],
115            listID: 0,
116            listRID: mailingList.rid,
117            listName: mailingList.name,
118            listOwner: mailingList.owner,
119            subject: thread.subject,
120            latestSender: thread.sender,
121            lastActivityAt: thread.updated,
122            messageCount: thread.replies + 1,
123            repo: nil,
124            containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
125            isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
126        )
127    }
128
129    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
130        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
131        let current = threads[index]
132        threads[index] = InboxThreadSummary(
133            rootEmailID: current.rootEmailID,
134            rootMessageID: current.rootMessageID,
135            threadRootEmailIDs: current.threadRootEmailIDs,
136            threadRootMessageIDs: current.threadRootMessageIDs,
137            listID: current.listID,
138            listRID: current.listRID,
139            listName: current.listName,
140            listOwner: current.listOwner,
141            subject: current.subject,
142            latestSender: current.latestSender,
143            lastActivityAt: current.lastActivityAt,
144            messageCount: current.messageCount,
145            repo: current.repo,
146            containsPatch: current.containsPatch,
147            isUnread: isUnread
148        )
149    }
150
151    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
152        var grouped: [String: InboxThreadSummary] = [:]
153
154        for thread in threads {
155            guard let existing = grouped[thread.threadGroupingKey] else {
156                grouped[thread.threadGroupingKey] = thread
157                continue
158            }
159
160            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
161            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
162            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
163            let mergedMessageCount = max(
164                existing.messageCount ?? existing.threadRootMessageIDs.count,
165                thread.messageCount ?? thread.threadRootMessageIDs.count,
166                mergedRootMessageIDs.count
167            )
168
169            grouped[thread.threadGroupingKey] = InboxThreadSummary(
170                rootEmailID: latest.rootEmailID,
171                rootMessageID: latest.rootMessageID,
172                threadRootEmailIDs: mergedRootEmailIDs,
173                threadRootMessageIDs: mergedRootMessageIDs,
174                listID: latest.listID,
175                listRID: latest.listRID,
176                listName: latest.listName,
177                listOwner: latest.listOwner,
178                subject: latest.subject,
179                latestSender: latest.latestSender,
180                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
181                messageCount: mergedMessageCount,
182                repo: latest.repo ?? existing.repo,
183                containsPatch: latest.containsPatch || existing.containsPatch,
184                isUnread: latest.isUnread || existing.isUnread
185            )
186        }
187
188        return grouped.values.sorted { lhs, rhs in
189            if lhs.lastActivityAt == rhs.lastActivityAt {
190                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
191            }
192            return lhs.lastActivityAt > rhs.lastActivityAt
193        }
194    }
195
196    nonisolated static func filterThreads(
197        _ threads: [InboxThreadSummary],
198        matching query: String
199    ) -> [InboxThreadSummary] {
200        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
201        guard !q.isEmpty else { return threads }
202        return threads.filter {
203            normalizedSubject(from: $0.subject).contains(q) ||
204            $0.latestSender.canonicalName.lowercased().contains(q)
205        }
206    }
207
208    private nonisolated static func normalizedSubject(from subject: String) -> String {
209        subject
210            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
211            .trimmingCharacters(in: .whitespacesAndNewlines)
212            .replacingOccurrences(
213                of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
214                with: "",
215                options: [.regularExpression, .caseInsensitive]
216            )
217            .lowercased()
218    }
219}
220
221struct MailingListDetailView: View {
222    let mailingList: InboxMailingListReference
223
224    @Environment(AppState.self) private var appState
225    @State private var viewModel: MailingListDetailViewModel?
226
227    var body: some View {
228        Group {
229            if let viewModel {
230                content(viewModel)
231            } else {
232                SRHTLoadingStateView(message: "Loading mailing list…")
233            }
234        }
235        .navigationTitle(mailingList.name)
236        .navigationBarTitleDisplayMode(.inline)
237        .task {
238            if viewModel == nil {
239                let viewModel = MailingListDetailViewModel(mailingList: mailingList, client: appState.client)
240                self.viewModel = viewModel
241                await viewModel.loadThreads()
242            }
243        }
244        .onAppear {
245            guard let viewModel else { return }
246            Task {
247                await viewModel.loadThreads()
248            }
249        }
250    }
251
252    @ViewBuilder
253    private func content(_ viewModel: MailingListDetailViewModel) -> some View {
254        @Bindable var vm = viewModel
255
256        List {
257            ForEach(viewModel.filteredThreads) { thread in
258                NavigationLink(value: MoreRoute.thread(thread)) {
259                    InboxThreadRow(thread: thread)
260                }
261                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
262                    Button {
263                        withAnimation(.easeInOut(duration: 0.2)) {
264                            if thread.isUnread {
265                                viewModel.markThreadRead(thread)
266                            } else {
267                                viewModel.markThreadUnread(thread)
268                            }
269                        }
270                    } label: {
271                        Label(
272                            thread.isUnread ? "Mark as Read" : "Mark as Unread",
273                            systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
274                        )
275                    }
276                    .tint(thread.isUnread ? .blue : .gray)
277                }
278            }
279        }
280        .listStyle(.plain)
281        .searchable(
282            text: $vm.searchText,
283            placement: .navigationBarDrawer(displayMode: .always),
284            prompt: "Search messages"
285        )
286        .overlay {
287            if viewModel.isLoading, viewModel.threads.isEmpty {
288                SRHTLoadingStateView(message: "Loading mailing list…")
289            } else if let error = viewModel.error, viewModel.threads.isEmpty {
290                SRHTErrorStateView(
291                    title: "Couldn't Load Mailing List",
292                    message: error,
293                    retryAction: { await viewModel.loadThreads() }
294                )
295            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
296                ContentUnavailableView.search(text: viewModel.searchText)
297            } else if viewModel.threads.isEmpty {
298                ContentUnavailableView(
299                    "No Threads",
300                    systemImage: "tray",
301                    description: Text("This mailing list does not have any recent threads.")
302                )
303            }
304        }
305        .refreshable {
306            await viewModel.loadThreads()
307        }
308        .srhtErrorBanner(error: $vm.error)
309    }
310}
311
312struct ProjectMailingListView: View {
313    let mailingList: Project.MailingList
314
315    var body: some View {
316        MailingListDetailView(mailingList: mailingList.inboxReference)
317    }
318}