krz/hutch

an ios client for sourcehut

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

v2: 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}