krz/hutch

an ios client for sourcehut

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

v3.11.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
 40private struct ListMetaResponse: Decodable, Sendable {
 41    let list: ListMetaPayload?
 42}
 43
 44private struct ListMetaPayload: Decodable, Sendable {
 45    let id: Int
 46    let owner: Entity
 47}
 48
 49private struct SubscriptionRidsResponse: Decodable, Sendable {
 50    let subscriptions: SubscriptionRidsPage
 51}
 52
 53private struct SubscriptionRidsPage: Decodable, Sendable {
 54    let results: [SubscriptionRidEntry]
 55    let cursor: String?
 56}
 57
 58private struct SubscriptionRidEntry: Decodable, Sendable {
 59    let list: SubscriptionRidList?
 60}
 61
 62private struct SubscriptionRidList: Decodable, Sendable {
 63    let rid: String
 64}
 65
 66/// Toggle mutations return the subscription (nullable on unsubscribe); only
 67/// success matters.
 68private struct SubscriptionToggleResponse: Decodable, Sendable {}
 69
 70@Observable
 71@MainActor
 72final class MailingListDetailViewModel {
 73    private(set) var threads: [InboxThreadSummary] = []
 74    /// Patchsets on this list, derived from thread roots  see the query below.
 75    private(set) var patchsets: [PatchsetSummary] = []
 76    private(set) var isLoading = false
 77    var error: String?
 78    var searchText = ""
 79
 80    /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable
 81    /// (the toggle stays hidden); `isOwnList` hides it for lists you own.
 82    private(set) var listNumericID: Int?
 83    private(set) var isSubscribed: Bool?
 84    private(set) var isOwnList = false
 85    private(set) var isTogglingSubscription = false
 86
 87    private let mailingList: InboxMailingListReference
 88    private let client: SRHTClient
 89    private let defaults: UserDefaults
 90    private let accountID: String
 91
 92    private static let listThreadsQuery = """
 93    query projectMailingListThreads($rid: ID!) {
 94        list(rid: $rid) {
 95            threads {
 96                results {
 97                    updated
 98                    subject
 99                    replies
100                    sender { canonicalName }
101                    root {
102                        id
103                        messageID
104                        patch { subject }
105                        patchset {
106                            id
107                            subject
108                            version
109                            prefix
110                            status
111                        }
112                    }
113                }
114            }
115        }
116    }
117    """
118
119    init(mailingList: InboxMailingListReference, client: SRHTClient, defaults: UserDefaults, accountID: String) {
120        self.mailingList = mailingList
121        self.client = client
122        self.defaults = defaults
123        self.accountID = accountID
124    }
125
126    // MARK: - Subscription
127
128    private static let listMetaQuery = """
129    query listMeta($rid: ID!) {
130        list(rid: $rid) { id owner { canonicalName } }
131    }
132    """
133
134    // `MailingList.subscription` is unreliable (see the API-traps note), so
135    // subscribe state comes from the authoritative `subscriptions` query.
136    private static let subscriptionRidsQuery = """
137    query subscriptionRids($cursor: Cursor) {
138        subscriptions(cursor: $cursor) {
139            results {
140                ... on MailingListSubscription { list { rid } }
141            }
142            cursor
143        }
144    }
145    """
146
147    private static let subscribeMutation = """
148    mutation mailingListSubscribe($id: Int!) {
149        mailingListSubscribe(listID: $id) { id }
150    }
151    """
152
153    private static let unsubscribeMutation = """
154    mutation mailingListUnsubscribe($id: Int!) {
155        mailingListUnsubscribe(listID: $id) { id }
156    }
157    """
158
159    /// Resolves the list's numeric id, whether the viewer owns it, and  for
160    /// lists they don't own  whether they're subscribed.
161    func loadSubscriptionState(currentUserCanonicalName: String?) async {
162        do {
163            let meta = try await client.execute(
164                service: .lists,
165                query: Self.listMetaQuery,
166                variables: ["rid": mailingList.rid],
167                responseType: ListMetaResponse.self
168            )
169            guard let list = meta.list else { return }
170            listNumericID = list.id
171
172            if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName {
173                isOwnList = true
174                return
175            }
176            isSubscribed = try await isSubscribed(toRid: mailingList.rid)
177        } catch {
178            // Leave state unknown; the toggle stays hidden rather than lying.
179        }
180    }
181
182    private func isSubscribed(toRid rid: String) async throws -> Bool {
183        var cursor: String?
184        repeat {
185            let response = try await client.execute(
186                service: .lists,
187                query: Self.subscriptionRidsQuery,
188                variables: cursor.map { ["cursor": $0] },
189                responseType: SubscriptionRidsResponse.self
190            )
191            if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) {
192                return true
193            }
194            cursor = response.subscriptions.cursor
195        } while cursor != nil
196        return false
197    }
198
199    func toggleSubscription() async {
200        guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return }
201        isTogglingSubscription = true
202        defer { isTogglingSubscription = false }
203        do {
204            _ = try await client.execute(
205                service: .lists,
206                query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation,
207                variables: ["id": id],
208                responseType: SubscriptionToggleResponse.self
209            )
210            isSubscribed = !subscribed
211        } catch {
212            self.error = error.userFacingMessage
213        }
214    }
215
216    var filteredThreads: [InboxThreadSummary] {
217        Self.filterThreads(threads, matching: searchText)
218    }
219
220    func loadThreads() async {
221        guard !isLoading else { return }
222        isLoading = true
223        error = nil
224        defer { isLoading = false }
225
226        do {
227            let response = try await client.execute(
228                service: .lists,
229                query: Self.listThreadsQuery,
230                variables: ["rid": mailingList.rid],
231                responseType: ProjectMailingListThreadsResponse.self
232            )
233
234            let activity = await MailingListActivityLoader.load(
235                client: client,
236                listRID: mailingList.rid,
237                since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast
238            )
239
240            threads = deduplicateThreads(
241                response.list.threads.results.map { makeSummary(from: $0, activity: activity) }
242            )
243            patchsets = Self.patchsets(from: response.list.threads.results)
244        } catch {
245            self.error = "Failed to load mailing list"
246        }
247    }
248
249    var filteredPatchsets: [PatchsetSummary] {
250        let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
251        guard !query.isEmpty else { return patchsets }
252        return patchsets.filter { $0.subject.lowercased().contains(query) }
253    }
254
255    /// Collects the patchsets opened by these threads, newest first.
256    ///
257    /// A revised series arrives as its own thread, so the same subject can appear
258    /// at several versions; they are kept as distinct patchsets and the version
259    /// chain is shown in the detail view.
260    private nonisolated static func patchsets(
261        from threads: [ProjectMailingListThreadPayload]
262    ) -> [PatchsetSummary] {
263        var seenIDs = Set<Int>()
264        var results: [PatchsetSummary] = []
265
266        for thread in threads {
267            guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
268            seenIDs.insert(payload.id)
269            results.append(
270                PatchsetSummary(
271                    id: payload.id,
272                    subject: payload.subject,
273                    version: payload.version,
274                    prefix: payload.prefix,
275                    status: payload.status
276                )
277            )
278        }
279
280        return results
281    }
282
283    func markThreadRead(_ thread: InboxThreadSummary) {
284        let viewedAt = max(Date(), thread.lastActivityAt)
285        InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
286        updateThread(thread, isUnread: false)
287        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
288    }
289
290    func markThreadUnread(_ thread: InboxThreadSummary) {
291        InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
292        updateThread(thread, isUnread: true)
293        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
294    }
295
296    func markAllThreadsRead() {
297        let unreadThreads = threads.filter(\.isUnread)
298        guard !unreadThreads.isEmpty else { return }
299
300        let viewedAt = Date()
301        for thread in unreadThreads {
302            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
303        }
304
305        threads = threads.map { thread in
306            guard thread.isUnread else { return thread }
307            return InboxThreadSummary(
308                rootEmailID: thread.rootEmailID,
309                rootMessageID: thread.rootMessageID,
310                threadRootEmailIDs: thread.threadRootEmailIDs,
311                threadRootMessageIDs: thread.threadRootMessageIDs,
312                listID: thread.listID,
313                listRID: thread.listRID,
314                listName: thread.listName,
315                listOwner: thread.listOwner,
316                subject: thread.subject,
317                latestSender: thread.latestSender,
318                lastActivityAt: thread.lastActivityAt,
319                messageCount: thread.messageCount,
320                repo: thread.repo,
321                containsPatch: thread.containsPatch,
322                isUnread: false
323            )
324        }
325
326        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -unreadThreads.count, accountID: accountID)
327    }
328
329    private func makeSummary(
330        from thread: ProjectMailingListThreadPayload,
331        activity: MailingListActivity
332    ) -> InboxThreadSummary {
333        let normalizedSubject = thread.subject
334            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
335            .trimmingCharacters(in: .whitespacesAndNewlines)
336            .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
337            .lowercased()
338        let threadID = "\(mailingList.rid)#\(normalizedSubject)"
339
340        // thread.updated is the root email's insert time and never advances when a
341        // reply lands, so activity has to come from the list's mail feed.
342        let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated)
343
344        return InboxThreadSummary(
345            rootEmailID: thread.root.id,
346            rootMessageID: thread.root.messageID,
347            threadRootEmailIDs: [thread.root.id],
348            threadRootMessageIDs: [thread.root.messageID],
349            listID: 0,
350            listRID: mailingList.rid,
351            listName: mailingList.name,
352            listOwner: mailingList.owner,
353            subject: thread.subject,
354            latestSender: thread.sender,
355            lastActivityAt: lastActivityAt,
356            messageCount: thread.replies + 1,
357            repo: nil,
358            containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
359            isUnread: InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: lastActivityAt, defaults: defaults)
360        )
361    }
362
363    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
364        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
365        let current = threads[index]
366        threads[index] = InboxThreadSummary(
367            rootEmailID: current.rootEmailID,
368            rootMessageID: current.rootMessageID,
369            threadRootEmailIDs: current.threadRootEmailIDs,
370            threadRootMessageIDs: current.threadRootMessageIDs,
371            listID: current.listID,
372            listRID: current.listRID,
373            listName: current.listName,
374            listOwner: current.listOwner,
375            subject: current.subject,
376            latestSender: current.latestSender,
377            lastActivityAt: current.lastActivityAt,
378            messageCount: current.messageCount,
379            repo: current.repo,
380            containsPatch: current.containsPatch,
381            isUnread: isUnread
382        )
383    }
384
385    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
386        var grouped: [String: InboxThreadSummary] = [:]
387
388        for thread in threads {
389            guard let existing = grouped[thread.threadGroupingKey] else {
390                grouped[thread.threadGroupingKey] = thread
391                continue
392            }
393
394            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
395            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
396            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
397            let mergedMessageCount = max(
398                existing.messageCount ?? existing.threadRootMessageIDs.count,
399                thread.messageCount ?? thread.threadRootMessageIDs.count,
400                mergedRootMessageIDs.count
401            )
402
403            grouped[thread.threadGroupingKey] = InboxThreadSummary(
404                rootEmailID: latest.rootEmailID,
405                rootMessageID: latest.rootMessageID,
406                threadRootEmailIDs: mergedRootEmailIDs,
407                threadRootMessageIDs: mergedRootMessageIDs,
408                listID: latest.listID,
409                listRID: latest.listRID,
410                listName: latest.listName,
411                listOwner: latest.listOwner,
412                subject: latest.subject,
413                latestSender: latest.latestSender,
414                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
415                messageCount: mergedMessageCount,
416                repo: latest.repo ?? existing.repo,
417                containsPatch: latest.containsPatch || existing.containsPatch,
418                isUnread: latest.isUnread || existing.isUnread
419            )
420        }
421
422        return grouped.values.sorted { lhs, rhs in
423            if lhs.lastActivityAt == rhs.lastActivityAt {
424                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
425            }
426            return lhs.lastActivityAt > rhs.lastActivityAt
427        }
428    }
429
430    nonisolated static func filterThreads(
431        _ threads: [InboxThreadSummary],
432        matching query: String
433    ) -> [InboxThreadSummary] {
434        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
435        guard !q.isEmpty else { return threads }
436        return threads.filter {
437            normalizedSubject(from: $0.subject).contains(q) ||
438            $0.latestSender.canonicalName.lowercased().contains(q)
439        }
440    }
441
442    private nonisolated static func normalizedSubject(from subject: String) -> String {
443        subject
444            .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
445            .trimmingCharacters(in: .whitespacesAndNewlines)
446            .replacingOccurrences(
447                of: #"^(?:(?:re|fwd?)\s*:\s*)+"#,
448                with: "",
449                options: [.regularExpression, .caseInsensitive]
450            )
451            .lowercased()
452    }
453}
454
455enum MailingListScope: String, CaseIterable, Hashable {
456    case threads
457    case patches
458
459    var displayName: String {
460        switch self {
461        case .threads: "Threads"
462        case .patches: "Patches"
463        }
464    }
465}
466
467struct MailingListDetailView: View {
468    let mailingList: InboxMailingListReference
469
470    @Environment(AppState.self) private var appState
471    @State private var viewModel: MailingListDetailViewModel?
472    @State private var pinChangeCount = 0
473    @State private var scope: MailingListScope = .threads
474
475    private var currentUserKey: String? {
476        appState.currentUser?.canonicalName
477    }
478
479    private var isPinnedToHome: Bool {
480        _ = pinChangeCount
481        guard let currentUserKey else { return false }
482        return HomePinStore.isPinned(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
483    }
484
485    private var hasUnreadThreads: Bool {
486        viewModel?.threads.contains(where: \.isUnread) == true
487    }
488
489    var body: some View {
490        Group {
491            if let viewModel {
492                content(viewModel)
493            } else {
494                SRHTLoadingStateView(message: "Loading mailing list…")
495            }
496        }
497        .navigationTitle(mailingList.name)
498        .navigationBarTitleDisplayMode(.inline)
499        .toolbar {
500            ToolbarItem(placement: .topBarTrailing) {
501                Button("Mark All Read") {
502                    viewModel?.markAllThreadsRead()
503                }
504                .disabled(hasUnreadThreads == false)
505            }
506            if currentUserKey != nil {
507                ToolbarItem(placement: .topBarTrailing) {
508                    Button {
509                        togglePinnedState()
510                    } label: {
511                        Image(systemName: isPinnedToHome ? "pin.fill" : "pin")
512                    }
513                    .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
514                }
515            }
516            if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed {
517                ToolbarItem(placement: .topBarTrailing) {
518                    Button {
519                        Task { await viewModel.toggleSubscription() }
520                    } label: {
521                        if viewModel.isTogglingSubscription {
522                            ProgressView().controlSize(.small)
523                        } else {
524                            Image(systemName: subscribed ? "bell.fill" : "bell")
525                        }
526                    }
527                    .disabled(viewModel.isTogglingSubscription)
528                    .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list")
529                }
530            }
531        }
532        .task {
533            if viewModel == nil {
534                let viewModel = MailingListDetailViewModel(
535                    mailingList: mailingList,
536                    client: appState.client,
537                    defaults: appState.accountDefaults,
538                    accountID: appState.activeAccountID
539                )
540                self.viewModel = viewModel
541                await viewModel.loadThreads()
542                await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
543            }
544        }
545        .onAppear {
546            guard let viewModel else { return }
547            Task {
548                await viewModel.loadThreads()
549            }
550        }
551    }
552
553    private func togglePinnedState() {
554        guard let currentUserKey else { return }
555        HomePinStore.togglePin(.mailingList(mailingList), for: currentUserKey, defaults: appState.accountDefaults)
556        pinChangeCount += 1
557    }
558
559    @ViewBuilder
560    private func content(_ viewModel: MailingListDetailViewModel) -> some View {
561        @Bindable var vm = viewModel
562
563        List {
564            // Only offered when the list actually carries patches, so discussion
565            // lists do not grow an empty tab.
566            if !viewModel.patchsets.isEmpty {
567                Picker("Scope", selection: $scope) {
568                    ForEach(MailingListScope.allCases, id: \.self) { scope in
569                        Text(scope.displayName).tag(scope)
570                    }
571                }
572                .pickerStyle(.segmented)
573                .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
574                .themedRow()
575            }
576
577            if showingPatches(viewModel) {
578                ForEach(viewModel.filteredPatchsets) { patchset in
579                    // Pushed directly rather than by value: this view is also shown
580                    // from a project, whose stack declares no MoreRoute destination.
581                    NavigationLink {
582                        PatchsetDetailView(patchsetID: patchset.id, listName: mailingList.name)
583                    } label: {
584                        PatchsetRow(patchset: patchset)
585                    }
586                    .themedRow()
587                }
588            } else {
589            ForEach(viewModel.filteredThreads) { thread in
590                NavigationLink {
591                    ThreadDetailView(
592                        thread: thread,
593                        onViewed: {
594                            viewModel.markThreadRead(thread)
595                        },
596                        onMarkRead: {
597                            viewModel.markThreadRead(thread)
598                        },
599                        onMarkUnread: {
600                            viewModel.markThreadUnread(thread)
601                        }
602                    )
603                } label: {
604                    InboxThreadRow(thread: thread)
605                }
606                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
607                    Button {
608                        withAnimation(.easeInOut(duration: 0.2)) {
609                            if thread.isUnread {
610                                viewModel.markThreadRead(thread)
611                            } else {
612                                viewModel.markThreadUnread(thread)
613                            }
614                        }
615                    } label: {
616                        Label(
617                            thread.isUnread ? "Mark as Read" : "Mark as Unread",
618                            systemImage: thread.isUnread ? "envelope.open" : "envelope.badge"
619                        )
620                    }
621                    .tint(thread.isUnread ? .blue : .gray)
622                }
623            }
624            .themedRow()
625            }
626        }
627        .themedList()
628        .listStyle(.plain)
629        .searchable(
630            text: $vm.searchText,
631            placement: .navigationBarDrawer(displayMode: .always),
632            prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
633        )
634        .overlay {
635            if viewModel.isLoading, viewModel.threads.isEmpty {
636                SRHTLoadingStateView(message: "Loading mailing list…")
637            } else if let error = viewModel.error, viewModel.threads.isEmpty {
638                SRHTErrorStateView(
639                    title: "Couldn't Load Mailing List",
640                    message: error,
641                    retryAction: { await viewModel.loadThreads() }
642                )
643            } else if showingPatches(viewModel) {
644                if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
645                    ContentUnavailableView.search(text: viewModel.searchText)
646                }
647            } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
648                ContentUnavailableView.search(text: viewModel.searchText)
649            } else if viewModel.threads.isEmpty {
650                ContentUnavailableView(
651                    "No Threads",
652                    systemImage: "tray",
653                    description: Text("This mailing list does not have any recent threads.")
654                )
655            }
656        }
657        .refreshable {
658            await viewModel.loadThreads()
659        }
660        .srhtErrorBanner(error: $vm.error)
661    }
662
663    private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
664        scope == .patches && !viewModel.patchsets.isEmpty
665    }
666}
667
668struct PatchsetRow: View {
669    let patchset: PatchsetSummary
670
671    var body: some View {
672        VStack(alignment: .leading, spacing: 6) {
673            Text(patchset.subject)
674                .font(.subheadline.weight(.medium))
675                .lineLimit(2)
676
677            HStack(spacing: 8) {
678                PatchsetStatusBadge(status: patchset.status)
679                if let versionLabel = patchset.versionLabel {
680                    Text(versionLabel)
681                        .font(.caption.weight(.medium))
682                        .foregroundStyle(.secondary)
683                }
684            }
685        }
686        .padding(.vertical, 2)
687        .accessibilityElement(children: .combine)
688        .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
689    }
690}
691
692struct ProjectMailingListView: View {
693    let mailingList: Project.MailingList
694
695    var body: some View {
696        MailingListDetailView(mailingList: mailingList.inboxReference)
697    }
698}