krz/hutch

an ios client for sourcehut

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

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