krz/hutch

an ios client for sourcehut

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

v3.5.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    private let defaults: UserDefaults
 40    private let accountID: String
 41
 42    private static let listThreadsQuery = """
 43    query projectMailingListThreads($rid: ID!) {
 44        list(rid: $rid) {
 45            threads {
 46                results {
 47                    updated
 48                    subject
 49                    replies
 50                    sender { canonicalName }
 51                    root {
 52                        id
 53                        messageID
 54                        patch { subject }
 55                    }
 56                }
 57            }
 58        }
 59    }
 60    """
 61
 62    init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
 63        self.mailingList = mailingList
 64        self.client = client
 65        self.defaults = defaults
 66        self.accountID = accountID
 67    }
 68
 69    var filteredThreads: [InboxThreadSummary] {
 70        Self.filterThreads(threads, matching: searchText)
 71    }
 72
 73    func loadThreads() async {
 74        guard !isLoading else { return }
 75        isLoading = true
 76        error = nil
 77        defer { isLoading = false }
 78
 79        do {
 80            let response = try await client.execute(
 81                service: .lists,
 82                query: Self.listThreadsQuery,
 83                variables: ["rid": mailingList.rid],
 84                responseType: ProjectMailingListThreadsResponse.self
 85            )
 86
 87            threads = deduplicateThreads(
 88                response.list.threads.results.map(makeSummary(from:))
 89            )
 90        } catch {
 91            self.error = "Failed to load mailing list"
 92        }
 93    }
 94
 95    func markThreadRead(_ thread: InboxThreadSummary) {
 96        let viewedAt = max(Date(), thread.lastActivityAt)
 97        InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
 98        updateThread(thread, isUnread: false)
 99        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
100    }
101
102    func markThreadUnread(_ thread: InboxThreadSummary) {
103        InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
104        updateThread(thread, isUnread: true)
105        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
106    }
107
108    func markAllThreadsRead() {
109        let unreadThreads = threads.filter(\.isUnread)
110        guard !unreadThreads.isEmpty else { return }
111
112        let viewedAt = Date()
113        for thread in unreadThreads {
114            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
115        }
116
117        threads = threads.map { thread in
118            guard thread.isUnread else { return thread }
119            return InboxThreadSummary(
120                rootEmailID: thread.rootEmailID,
121                rootMessageID: thread.rootMessageID,
122                threadRootEmailIDs: thread.threadRootEmailIDs,
123                threadRootMessageIDs: thread.threadRootMessageIDs,
124                listID: thread.listID,
125                listRID: thread.listRID,
126                listName: thread.listName,
127                listOwner: thread.listOwner,
128                subject: thread.subject,
129                latestSender: thread.latestSender,
130                lastActivityAt: thread.lastActivityAt,
131                messageCount: thread.messageCount,
132                repo: thread.repo,
133                containsPatch: thread.containsPatch,
134                isUnread: false
135            )
136        }
137
138        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
139    }
140
141    private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
142        let normalizedSubject = thread.subject
143            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
144            .trimmingCharacters(in: .whitespacesAndNewlines)
145            .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
146            .lowercased()
147        let threadID = "\(mailingList.rid)#\(normalizedSubject)"
148
149        return InboxThreadSummary(
150            rootEmailID: thread.root.id,
151            rootMessageID: thread.root.messageID,
152            threadRootEmailIDs: [thread.root.id],
153            threadRootMessageIDs: [thread.root.messageID],
154            listID: 0,
155            listRID: mailingList.rid,
156            listName: mailingList.name,
157            listOwner: mailingList.owner,
158            subject: thread.subject,
159            latestSender: thread.sender,
160            lastActivityAt: thread.updated,
161            messageCount: thread.replies + 1,
162            repo: nil,
163            containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
164            isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
165        )
166    }
167
168    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
169        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
170        let current = threads[index]
171        threads[index] = InboxThreadSummary(
172            rootEmailID: current.rootEmailID,
173            rootMessageID: current.rootMessageID,
174            threadRootEmailIDs: current.threadRootEmailIDs,
175            threadRootMessageIDs: current.threadRootMessageIDs,
176            listID: current.listID,
177            listRID: current.listRID,
178            listName: current.listName,
179            listOwner: current.listOwner,
180            subject: current.subject,
181            latestSender: current.latestSender,
182            lastActivityAt: current.lastActivityAt,
183            messageCount: current.messageCount,
184            repo: current.repo,
185            containsPatch: current.containsPatch,
186            isUnread: isUnread
187        )
188    }
189
190    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
191        var grouped: [String: InboxThreadSummary] = [:]
192
193        for thread in threads {
194            guard let existing = grouped[thread.threadGroupingKey] else {
195                grouped[thread.threadGroupingKey] = thread
196                continue
197            }
198
199            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
200            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
201            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
202            let mergedMessageCount = max(
203                existing.messageCount ?? existing.threadRootMessageIDs.count,
204                thread.messageCount ?? thread.threadRootMessageIDs.count,
205                mergedRootMessageIDs.count
206            )
207
208            grouped[thread.threadGroupingKey] = InboxThreadSummary(
209                rootEmailID: latest.rootEmailID,
210                rootMessageID: latest.rootMessageID,
211                threadRootEmailIDs: mergedRootEmailIDs,
212                threadRootMessageIDs: mergedRootMessageIDs,
213                listID: latest.listID,
214                listRID: latest.listRID,
215                listName: latest.listName,
216                listOwner: latest.listOwner,
217                subject: latest.subject,
218                latestSender: latest.latestSender,
219                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
220                messageCount: mergedMessageCount,
221                repo: latest.repo ?? existing.repo,
222                containsPatch: latest.containsPatch || existing.containsPatch,
223                isUnread: latest.isUnread || existing.isUnread
224            )
225        }
226
227        return grouped.values.sorted { lhs, rhs in
228            if lhs.lastActivityAt == rhs.lastActivityAt {
229                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
230            }
231            return lhs.lastActivityAt > rhs.lastActivityAt
232        }
233    }
234
235    nonisolated static func filterThreads(
236        _ threads: [InboxThreadSummary],
237        matching query: String
238    ) -> [InboxThreadSummary] {
239        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
240        guard !q.isEmpty else { return threads }
241        return threads.filter {
242            normalizedSubject(from: $0.subject).contains(q) ||
243            $0.latestSender.canonicalName.lowercased().contains(q)
244        }
245    }
246
247    private nonisolated static func normalizedSubject(from subject: String) -> String {
248        subject
249            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
250            .trimmingCharacters(in: .whitespacesAndNewlines)
251            .replacingOccurrences(
252                of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
253                with: "",
254                options: [.regularExpression, .caseInsensitive]
255            )
256            .lowercased()
257    }
258}
259
260struct MailingListDetailView: View {
261    let mailingList: InboxMailingListReference
262
263    @Environment(AppState.self) private var appState
264    @State private var viewModel: MailingListDetailViewModel?
265    @State private var pinChangeCount = 0
266
267    private var currentUserKey: String? {
268        appState.currentUser?.canonicalName
269    }
270
271    private var isPinnedToHome: Bool {
272        _ = pinChangeCount
273        guard let currentUserKey else { return false }
274        return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
275    }
276
277    private var hasUnreadThreads: Bool {
278        viewModel?.threads.contains(where: \.isUnread) == true
279    }
280
281    var body: some View {
282        Group {
283            if let viewModel {
284                content(viewModel)
285            } else {
286                SRHTLoadingStateView(message: "Loading mailing list…")
287            }
288        }
289        .navigationTitle(mailingList.name)
290        .navigationBarTitleDisplayMode(.inline)
291        .toolbar {
292            ToolbarItem(placement: .topBarTrailing) {
293                Button("Mark All Read") {
294                    viewModel?.markAllThreadsRead()
295                }
296                .disabled(hasUnreadThreads == false)
297            }
298            if currentUserKey != nil {
299                ToolbarItem(placement: .topBarTrailing) {
300                    Button {
301                        togglePinnedState()
302                    } label: {
303                        Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
304                    }
305                    .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
306                }
307            }
308        }
309        .task {
310            if viewModel == nil {
311                let viewModel = MailingListDetailViewModel(
312                    mailingList: mailingList,
313                    client: appState.client,
314                    defaults: appState.accountDefaults,
315                    accountID: appState.activeAccountID
316                )
317                self.viewModel = viewModel
318                await viewModel.loadThreads()
319            }
320        }
321        .onAppear {
322            guard let viewModel else { return }
323            Task {
324                await viewModel.loadThreads()
325            }
326        }
327    }
328
329    private func togglePinnedState() {
330        guard let currentUserKey else { return }
331        HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
332        pinChangeCount += 1
333    }
334
335    @ViewBuilder
336    private func content(_ viewModel: MailingListDetailViewModel) -> some View {
337        @Bindable var vm = viewModel
338
339        List {
340            ForEach(viewModel.filteredThreads) { thread in
341                NavigationLink {
342                    ThreadDetailView(
343                        thread: thread,
344                        onViewed: {
345                            viewModel.markThreadRead(thread)
346                        },
347                        onMarkRead: {
348                            viewModel.markThreadRead(thread)
349                        },
350                        onMarkUnread: {
351                            viewModel.markThreadUnread(thread)
352                        }
353                    )
354                } label: {
355                    InboxThreadRow(thread: thread)
356                }
357                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
358                    Button {
359                        withAnimation(.easeInOut(duration: 0.2)) {
360                            if thread.isUnread {
361                                viewModel.markThreadRead(thread)
362                            } else {
363                                viewModel.markThreadUnread(thread)
364                            }
365                        }
366                    } label: {
367                        Label(
368                            thread.isUnread ? "Mark as Read" : "Mark as Unread",
369                            systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
370                        )
371                    }
372                    .tint(thread.isUnread ? .blue : .gray)
373                }
374            }
375            .themedRow()
376        }
377        .themedList()
378        .listStyle(.plain)
379        .searchable(
380            text: $vm.searchText,
381            placement: .navigationBarDrawer(displayMode: .always),
382            prompt: "Search messages"
383        )
384        .overlay {
385            if viewModel.isLoading, viewModel.threads.isEmpty {
386                SRHTLoadingStateView(message: "Loading mailing list…")
387            } else if let error = viewModel.error, viewModel.threads.isEmpty {
388                SRHTErrorStateView(
389                    title: "Couldn't Load Mailing List",
390                    message: error,
391                    retryAction: { await viewModel.loadThreads() }
392                )
393            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
394                ContentUnavailableView.search(text: viewModel.searchText)
395            } else if viewModel.threads.isEmpty {
396                ContentUnavailableView(
397                    "No Threads",
398                    systemImage: "tray",
399                    description: Text("This mailing list does not have any recent threads.")
400                )
401            }
402        }
403        .refreshable {
404            await viewModel.loadThreads()
405        }
406        .srhtErrorBanner(error: $vm.error)
407    }
408}
409
410struct ProjectMailingListView: View {
411    let mailingList: Project.MailingList
412
413    var body: some View {
414        MailingListDetailView(mailingList: mailingList.inboxReference)
415    }
416}