krz/hutch

an ios client for sourcehut

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

v3.7.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    /// Null unless the thread's root email opens a patchset. `MailingList` has no
 28    /// patchsets field, so this is the only way to enumerate a list's patchsets.
 29    let patchset: PatchsetSummaryPayload?
 30}
 31
 32private struct PatchsetSummaryPayload: Decodable, Sendable {
 33    let id: Int
 34    let subject: String
 35    let version: Int
 36    let prefix: String?
 37    let status: PatchsetStatus
 38}
 39
 40@Observable
 41@MainActor
 42final class MailingListDetailViewModel {
 43    private(set) var threads: [InboxThreadSummary] = []
 44    /// Patchsets on this list, derived from thread roots  see the query below.
 45    private(set) var patchsets: [PatchsetSummary] = []
 46    private(set) var isLoading = false
 47    var error: String?
 48    var searchText = ""
 49
 50    private let mailingList: InboxMailingListReference
 51    private let client: SRHTClient
 52    private let defaults: UserDefaults
 53    private let accountID: String
 54
 55    private static let listThreadsQuery = """
 56    query projectMailingListThreads($rid: ID!) {
 57        list(rid: $rid) {
 58            threads {
 59                results {
 60                    updated
 61                    subject
 62                    replies
 63                    sender { canonicalName }
 64                    root {
 65                        id
 66                        messageID
 67                        patch { subject }
 68                        patchset {
 69                            id
 70                            subject
 71                            version
 72                            prefix
 73                            status
 74                        }
 75                    }
 76                }
 77            }
 78        }
 79    }
 80    """
 81
 82    init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
 83        self.mailingList = mailingList
 84        self.client = client
 85        self.defaults = defaults
 86        self.accountID = accountID
 87    }
 88
 89    var filteredThreads: [InboxThreadSummary] {
 90        Self.filterThreads(threads, matching: searchText)
 91    }
 92
 93    func loadThreads() async {
 94        guard !isLoading else { return }
 95        isLoading = true
 96        error = nil
 97        defer { isLoading = false }
 98
 99        do {
100            let response = try await client.execute(
101                service: .lists,
102                query: Self.listThreadsQuery,
103                variables: ["rid": mailingList.rid],
104                responseType: ProjectMailingListThreadsResponse.self
105            )
106
107            threads = deduplicateThreads(
108                response.list.threads.results.map(makeSummary(from:))
109            )
110            patchsets = Self.patchsets(from: response.list.threads.results)
111        } catch {
112            self.error = "Failed to load mailing list"
113        }
114    }
115
116    var filteredPatchsets: [PatchsetSummary] {
117        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
118        guard !query.isEmpty else { return patchsets }
119        return patchsets.filter { $0.subject.lowercased().contains(query) }
120    }
121
122    /// Collects the patchsets opened by these threads, newest first.
123    ///
124    /// A revised series arrives as its own thread, so the same subject can appear
125    /// at several versions; they are kept as distinct patchsets and the version
126    /// chain is shown in the detail view.
127    private nonisolated static func patchsets(
128        from threads: [ProjectMailingListThreadPayload]
129    ) -> [PatchsetSummary] {
130        var seenIDs = Set<Int>()
131        var results: [PatchsetSummary] = []
132
133        for thread in threads {
134            guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
135            seenIDs.insert(payload.id)
136            results.append(
137                PatchsetSummary(
138                    id: payload.id,
139                    subject: payload.subject,
140                    version: payload.version,
141                    prefix: payload.prefix,
142                    status: payload.status
143                )
144            )
145        }
146
147        return results
148    }
149
150    func markThreadRead(_ thread: InboxThreadSummary) {
151        let viewedAt = max(Date(), thread.lastActivityAt)
152        InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
153        updateThread(thread, isUnread: false)
154        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
155    }
156
157    func markThreadUnread(_ thread: InboxThreadSummary) {
158        InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
159        updateThread(thread, isUnread: true)
160        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
161    }
162
163    func markAllThreadsRead() {
164        let unreadThreads = threads.filter(\.isUnread)
165        guard !unreadThreads.isEmpty else { return }
166
167        let viewedAt = Date()
168        for thread in unreadThreads {
169            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
170        }
171
172        threads = threads.map { thread in
173            guard thread.isUnread else { return thread }
174            return InboxThreadSummary(
175                rootEmailID: thread.rootEmailID,
176                rootMessageID: thread.rootMessageID,
177                threadRootEmailIDs: thread.threadRootEmailIDs,
178                threadRootMessageIDs: thread.threadRootMessageIDs,
179                listID: thread.listID,
180                listRID: thread.listRID,
181                listName: thread.listName,
182                listOwner: thread.listOwner,
183                subject: thread.subject,
184                latestSender: thread.latestSender,
185                lastActivityAt: thread.lastActivityAt,
186                messageCount: thread.messageCount,
187                repo: thread.repo,
188                containsPatch: thread.containsPatch,
189                isUnread: false
190            )
191        }
192
193        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
194    }
195
196    private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
197        let normalizedSubject = thread.subject
198            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
199            .trimmingCharacters(in: .whitespacesAndNewlines)
200            .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
201            .lowercased()
202        let threadID = "\(mailingList.rid)#\(normalizedSubject)"
203
204        return InboxThreadSummary(
205            rootEmailID: thread.root.id,
206            rootMessageID: thread.root.messageID,
207            threadRootEmailIDs: [thread.root.id],
208            threadRootMessageIDs: [thread.root.messageID],
209            listID: 0,
210            listRID: mailingList.rid,
211            listName: mailingList.name,
212            listOwner: mailingList.owner,
213            subject: thread.subject,
214            latestSender: thread.sender,
215            lastActivityAt: thread.updated,
216            messageCount: thread.replies + 1,
217            repo: nil,
218            containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
219            isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated, defaults: defaults)
220        )
221    }
222
223    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
224        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
225        let current = threads[index]
226        threads[index] = InboxThreadSummary(
227            rootEmailID: current.rootEmailID,
228            rootMessageID: current.rootMessageID,
229            threadRootEmailIDs: current.threadRootEmailIDs,
230            threadRootMessageIDs: current.threadRootMessageIDs,
231            listID: current.listID,
232            listRID: current.listRID,
233            listName: current.listName,
234            listOwner: current.listOwner,
235            subject: current.subject,
236            latestSender: current.latestSender,
237            lastActivityAt: current.lastActivityAt,
238            messageCount: current.messageCount,
239            repo: current.repo,
240            containsPatch: current.containsPatch,
241            isUnread: isUnread
242        )
243    }
244
245    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
246        var grouped: [String: InboxThreadSummary] = [:]
247
248        for thread in threads {
249            guard let existing = grouped[thread.threadGroupingKey] else {
250                grouped[thread.threadGroupingKey] = thread
251                continue
252            }
253
254            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
255            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
256            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
257            let mergedMessageCount = max(
258                existing.messageCount ?? existing.threadRootMessageIDs.count,
259                thread.messageCount ?? thread.threadRootMessageIDs.count,
260                mergedRootMessageIDs.count
261            )
262
263            grouped[thread.threadGroupingKey] = InboxThreadSummary(
264                rootEmailID: latest.rootEmailID,
265                rootMessageID: latest.rootMessageID,
266                threadRootEmailIDs: mergedRootEmailIDs,
267                threadRootMessageIDs: mergedRootMessageIDs,
268                listID: latest.listID,
269                listRID: latest.listRID,
270                listName: latest.listName,
271                listOwner: latest.listOwner,
272                subject: latest.subject,
273                latestSender: latest.latestSender,
274                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
275                messageCount: mergedMessageCount,
276                repo: latest.repo ?? existing.repo,
277                containsPatch: latest.containsPatch || existing.containsPatch,
278                isUnread: latest.isUnread || existing.isUnread
279            )
280        }
281
282        return grouped.values.sorted { lhs, rhs in
283            if lhs.lastActivityAt == rhs.lastActivityAt {
284                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
285            }
286            return lhs.lastActivityAt > rhs.lastActivityAt
287        }
288    }
289
290    nonisolated static func filterThreads(
291        _ threads: [InboxThreadSummary],
292        matching query: String
293    ) -> [InboxThreadSummary] {
294        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
295        guard !q.isEmpty else { return threads }
296        return threads.filter {
297            normalizedSubject(from: $0.subject).contains(q) ||
298            $0.latestSender.canonicalName.lowercased().contains(q)
299        }
300    }
301
302    private nonisolated static func normalizedSubject(from subject: String) -> String {
303        subject
304            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
305            .trimmingCharacters(in: .whitespacesAndNewlines)
306            .replacingOccurrences(
307                of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
308                with: "",
309                options: [.regularExpression, .caseInsensitive]
310            )
311            .lowercased()
312    }
313}
314
315enum MailingListScope: String, CaseIterable, Hashable {
316    case threads
317    case patches
318
319    var displayName: String {
320        switch self {
321        case .threads: "Threads"
322        case .patches: "Patches"
323        }
324    }
325}
326
327struct MailingListDetailView: View {
328    let mailingList: InboxMailingListReference
329
330    @Environment(AppState.self) private var appState
331    @State private var viewModel: MailingListDetailViewModel?
332    @State private var pinChangeCount = 0
333    @State private var scope: MailingListScope = .threads
334
335    private var currentUserKey: String? {
336        appState.currentUser?.canonicalName
337    }
338
339    private var isPinnedToHome: Bool {
340        _ = pinChangeCount
341        guard let currentUserKey else { return false }
342        return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
343    }
344
345    private var hasUnreadThreads: Bool {
346        viewModel?.threads.contains(where: \.isUnread) == true
347    }
348
349    var body: some View {
350        Group {
351            if let viewModel {
352                content(viewModel)
353            } else {
354                SRHTLoadingStateView(message: "Loading mailing list…")
355            }
356        }
357        .navigationTitle(mailingList.name)
358        .navigationBarTitleDisplayMode(.inline)
359        .toolbar {
360            ToolbarItem(placement: .topBarTrailing) {
361                Button("Mark All Read") {
362                    viewModel?.markAllThreadsRead()
363                }
364                .disabled(hasUnreadThreads == false)
365            }
366            if currentUserKey != nil {
367                ToolbarItem(placement: .topBarTrailing) {
368                    Button {
369                        togglePinnedState()
370                    } label: {
371                        Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
372                    }
373                    .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
374                }
375            }
376        }
377        .task {
378            if viewModel == nil {
379                let viewModel = MailingListDetailViewModel(
380                    mailingList: mailingList,
381                    client: appState.client,
382                    defaults: appState.accountDefaults,
383                    accountID: appState.activeAccountID
384                )
385                self.viewModel = viewModel
386                await viewModel.loadThreads()
387            }
388        }
389        .onAppear {
390            guard let viewModel else { return }
391            Task {
392                await viewModel.loadThreads()
393            }
394        }
395    }
396
397    private func togglePinnedState() {
398        guard let currentUserKey else { return }
399        HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
400        pinChangeCount += 1
401    }
402
403    @ViewBuilder
404    private func content(_ viewModel: MailingListDetailViewModel) -> some View {
405        @Bindable var vm = viewModel
406
407        List {
408            // Only offered when the list actually carries patches, so discussion
409            // lists do not grow an empty tab.
410            if !viewModel.patchsets.isEmpty {
411                Picker("Scope", selection: $scope) {
412                    ForEach(MailingListScope.allCases, id: \.self) { scope in
413                        Text(scope.displayName).tag(scope)
414                    }
415                }
416                .pickerStyle(.segmented)
417                .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
418                .themedRow()
419            }
420
421            if showingPatches(viewModel) {
422                ForEach(viewModel.filteredPatchsets) { patchset in
423                    // Pushed directly rather than by value: this view is also shown
424                    // from a project, whose stack declares no MoreRoute destination.
425                    NavigationLink {
426                        PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
427                    } label: {
428                        PatchsetRow(patchset: patchset)
429                    }
430                    .themedRow()
431                }
432            } else {
433            ForEach(viewModel.filteredThreads) { thread in
434                NavigationLink {
435                    ThreadDetailView(
436                        thread: thread,
437                        onViewed: {
438                            viewModel.markThreadRead(thread)
439                        },
440                        onMarkRead: {
441                            viewModel.markThreadRead(thread)
442                        },
443                        onMarkUnread: {
444                            viewModel.markThreadUnread(thread)
445                        }
446                    )
447                } label: {
448                    InboxThreadRow(thread: thread)
449                }
450                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
451                    Button {
452                        withAnimation(.easeInOut(duration: 0.2)) {
453                            if thread.isUnread {
454                                viewModel.markThreadRead(thread)
455                            } else {
456                                viewModel.markThreadUnread(thread)
457                            }
458                        }
459                    } label: {
460                        Label(
461                            thread.isUnread ? "Mark as Read" : "Mark as Unread",
462                            systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
463                        )
464                    }
465                    .tint(thread.isUnread ? .blue : .gray)
466                }
467            }
468            .themedRow()
469            }
470        }
471        .themedList()
472        .listStyle(.plain)
473        .searchable(
474            text: $vm.searchText,
475            placement: .navigationBarDrawer(displayMode: .always),
476            prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
477        )
478        .overlay {
479            if viewModel.isLoading, viewModel.threads.isEmpty {
480                SRHTLoadingStateView(message: "Loading mailing list…")
481            } else if let error = viewModel.error, viewModel.threads.isEmpty {
482                SRHTErrorStateView(
483                    title: "Couldn't Load Mailing List",
484                    message: error,
485                    retryAction: { await viewModel.loadThreads() }
486                )
487            } else if showingPatches(viewModel) {
488                if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
489                    ContentUnavailableView.search(text: viewModel.searchText)
490                }
491            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
492                ContentUnavailableView.search(text: viewModel.searchText)
493            } else if viewModel.threads.isEmpty {
494                ContentUnavailableView(
495                    "No Threads",
496                    systemImage: "tray",
497                    description: Text("This mailing list does not have any recent threads.")
498                )
499            }
500        }
501        .refreshable {
502            await viewModel.loadThreads()
503        }
504        .srhtErrorBanner(error: $vm.error)
505    }
506
507    private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
508        scope == .patches && !viewModel.patchsets.isEmpty
509    }
510}
511
512struct PatchsetRow: View {
513    let patchset: PatchsetSummary
514
515    var body: some View {
516        VStack(alignment: .leading, spacing: 6) {
517            Text(patchset.subject)
518                .font(.subheadline.weight(.medium))
519                .lineLimit(2)
520
521            HStack(spacing: 8) {
522                PatchsetStatusBadge(status: patchset.status)
523                if let versionLabel = patchset.versionLabel {
524                    Text(versionLabel)
525                        .font(.caption.weight(.medium))
526                        .foregroundStyle(.secondary)
527                }
528            }
529        }
530        .padding(.vertical, 2)
531        .accessibilityElement(children: .combine)
532        .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
533    }
534}
535
536struct ProjectMailingListView: View {
537    let mailingList: Project.MailingList
538
539    var body: some View {
540        MailingListDetailView(mailingList: mailingList.inboxReference)
541    }
542}