krz/hutch

an ios client for sourcehut

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

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