krz/hutch

an ios client for sourcehut

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

v3.1.11: Hutch/Views/Inbox/InboxViewModel.swift · raw

  1import Foundation
  2import os
  3
  4private let inboxListLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxList")
  5
  6private struct InboxSubscriptionsResponse: Decodable, Sendable {
  7    let subscriptions: InboxSubscriptionPage
  8}
  9
 10private struct InboxSubscriptionPage: Decodable, Sendable {
 11    let results: [InboxActivitySubscription]
 12    let cursor: String?
 13}
 14
 15private struct InboxActivitySubscription: Decodable, Sendable {
 16    let id: Int
 17    let created: Date
 18    let list: InboxMailingListReference?
 19
 20    enum CodingKeys: String, CodingKey {
 21        case id
 22        case created
 23        case list
 24    }
 25}
 26
 27private struct InboxListThreadsResponse: Decodable, Sendable {
 28    let list: InboxMailingListThreads
 29}
 30
 31private struct InboxMailingListThreads: Decodable, Sendable {
 32    let threads: InboxThreadPage
 33}
 34
 35private struct InboxThreadPage: Decodable, Sendable {
 36    let results: [InboxThreadPayload]
 37    let cursor: String?
 38}
 39
 40private struct InboxThreadPayload: Decodable, Sendable {
 41    let created: Date
 42    let updated: Date
 43    let subject: String
 44    let replies: Int
 45    let sender: Entity
 46    let root: InboxEmailPreview
 47}
 48
 49private struct InboxEmailPreview: Decodable, Sendable {
 50    let id: Int
 51    let subject: String
 52    let date: Date?
 53    let received: Date
 54    let messageID: String
 55    let body: String
 56    let patch: InboxPatchPreview?
 57}
 58
 59enum InboxThreadFilter: String, CaseIterable, Sendable {
 60    case all = "All"
 61    case patches = "Patches"
 62    case discussions = "Talk"
 63}
 64
 65@Observable
 66@MainActor
 67final class InboxViewModel {
 68    private(set) var threads: [InboxThreadSummary] = []
 69    private(set) var isLoading = false
 70    var error: String?
 71    var filter: InboxThreadFilter = .all
 72    var searchText = ""
 73
 74    private let client: SRHTClient
 75    private let defaults: UserDefaults
 76    private let accountID: String
 77    private let listThreadFetchLimit = 10
 78    private let listFetchConcurrencyLimit = 4
 79
 80    private static let subscriptionsQuery = """
 81    query inboxSubscriptions($cursor: Cursor) {
 82        subscriptions(cursor: $cursor) {
 83            results {
 84                ... on MailingListSubscription {
 85                    id
 86                    created
 87                    list {
 88                        id
 89                        rid
 90                        name
 91                        owner { canonicalName }
 92                    }
 93                }
 94            }
 95            cursor
 96        }
 97    }
 98    """
 99
100    private static let listThreadsQuery = """
101    query inboxListThreads($rid: ID!, $cursor: Cursor) {
102        list(rid: $rid) {
103            threads(cursor: $cursor) {
104                results {
105                    created
106                    updated
107                    subject
108                    replies
109                    sender { canonicalName }
110                    root {
111                        id
112                        subject
113                        date
114                        received
115                        messageID
116                        body
117                        patch { subject }
118                    }
119                }
120                cursor
121            }
122        }
123    }
124    """
125
126    init(client: SRHTClient, defaults: UserDefaults, accountID: String) {
127        self.client = client
128        self.defaults = defaults
129        self.accountID = accountID
130    }
131
132    func loadThreads() async {
133        guard !isLoading else { return }
134        isLoading = true
135        error = nil
136        defer { isLoading = false }
137
138        do {
139            let subscriptions = try await fetchSubscriptions()
140            let mailingLists = deduplicateMailingLists(subscriptions.compactMap(\.list))
141            let fetchedThreads = try await fetchThreads(for: mailingLists)
142            threads = fetchedThreads
143                .filter(\.isUnread)
144                .sorted { lhs, rhs in
145                if lhs.lastActivityAt == rhs.lastActivityAt {
146                    return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending
147                }
148                return lhs.lastActivityAt > rhs.lastActivityAt
149            }
150            NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
151        } catch {
152            inboxListLogger.error("Inbox request failed")
153            self.error = "Failed to load inbox"
154        }
155    }
156
157    func markThreadRead(_ thread: InboxThreadSummary) {
158        let viewedAt = max(Date(), thread.lastActivityAt)
159        InboxReadStateStore.markViewed(viewedAt, for: thread.id, defaults: defaults)
160        threads.removeAll { $0.id == thread.id }
161        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1, accountID: accountID)
162    }
163
164    func markAllThreadsRead() {
165        guard !threads.isEmpty else { return }
166
167        let viewedAt = Date()
168        for thread in threads where thread.isUnread {
169            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
170        }
171
172        threads.removeAll { $0.isUnread }
173        NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count, accountID: accountID)
174    }
175
176    func markThreadUnread(_ thread: InboxThreadSummary) {
177        InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
178        updateThread(thread, isUnread: true)
179        NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1, accountID: accountID)
180    }
181
182    func toggleThreadReadState(_ thread: InboxThreadSummary) {
183        if thread.isUnread {
184            markThreadRead(thread)
185        } else {
186            markThreadUnread(thread)
187        }
188    }
189
190    func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
191        threads.first(where: { $0.id == id })
192    }
193
194    var filteredThreads: [InboxThreadSummary] {
195        let filteredByKind = Self.filterThreads(threads, filter: filter)
196        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
197        guard !q.isEmpty else { return filteredByKind }
198        return filteredByKind.filter {
199            $0.displaySubject.lowercased().contains(q) ||
200            $0.listName.lowercased().contains(q) ||
201            $0.latestSender.canonicalName.lowercased().contains(q)
202        }
203    }
204
205    var hasUnreadThreads: Bool {
206        threads.contains(where: \.isUnread)
207    }
208
209    private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
210        var subscriptions: [InboxActivitySubscription] = []
211        var cursor: String?
212
213        while true {
214            var variables: [String: any Sendable] = [:]
215            if let cursor {
216                variables["cursor"] = cursor
217            }
218
219            let response = try await client.execute(
220                service: .lists,
221                query: Self.subscriptionsQuery,
222                variables: variables.isEmpty ? nil : variables,
223                responseType: InboxSubscriptionsResponse.self
224            )
225
226            subscriptions.append(contentsOf: response.subscriptions.results)
227            guard let nextCursor = response.subscriptions.cursor else {
228                break
229            }
230            cursor = nextCursor
231        }
232
233        return subscriptions
234    }
235
236    private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
237        guard !mailingLists.isEmpty else { return [] }
238
239        var summaries: [InboxThreadSummary] = []
240        var startIndex = mailingLists.startIndex
241        var failureMessages: [String] = []
242
243        while startIndex < mailingLists.endIndex {
244            let endIndex = mailingLists.index(
245                startIndex,
246                offsetBy: listFetchConcurrencyLimit,
247                limitedBy: mailingLists.endIndex
248            ) ?? mailingLists.endIndex
249            let batch = Array(mailingLists[startIndex..<endIndex])
250
251            let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
252                for mailingList in batch {
253                    group.addTask {
254                        do {
255                            return (try await self.fetchThreads(for: mailingList), nil)
256                        } catch {
257                            return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
258                        }
259                    }
260                }
261
262                var batchSummaries: [InboxThreadSummary] = []
263                var batchFailures: [String] = []
264                for await result in group {
265                    batchSummaries.append(contentsOf: result.0)
266                    if let failure = result.1 {
267                        batchFailures.append(failure)
268                    }
269                }
270                return (batchSummaries, batchFailures)
271            }
272
273            summaries.append(contentsOf: batchResult.0)
274            failureMessages.append(contentsOf: batchResult.1)
275            for failure in batchResult.1 {
276                inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
277            }
278            startIndex = endIndex
279        }
280
281        if summaries.isEmpty, let firstFailure = failureMessages.first {
282            throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
283        }
284
285        return deduplicateThreads(summaries)
286    }
287
288    private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
289        let response = try await client.execute(
290            service: .lists,
291            query: Self.listThreadsQuery,
292            variables: ["rid": mailingList.rid],
293            responseType: InboxListThreadsResponse.self
294        )
295
296        return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
297            let groupingKey = "\(mailingList.rid)#\(thread.subject.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive]).lowercased())"
298            let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated, defaults: defaults)
299            return InboxThreadSummary(
300                rootEmailID: thread.root.id,
301                rootMessageID: thread.root.messageID,
302                threadRootEmailIDs: [thread.root.id],
303                threadRootMessageIDs: [thread.root.messageID],
304                listID: mailingList.id,
305                listRID: mailingList.rid,
306                listName: mailingList.name,
307                listOwner: mailingList.owner,
308                subject: thread.subject,
309                latestSender: thread.sender,
310                lastActivityAt: thread.updated,
311                messageCount: thread.replies + 1,
312                repo: Self.deriveRepositoryName(from: mailingList.name),
313                containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
314                isUnread: isUnread
315            )
316        }
317    }
318
319    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
320        var grouped: [String: InboxThreadSummary] = [:]
321
322        for thread in threads {
323            guard let existing = grouped[thread.threadGroupingKey] else {
324                grouped[thread.threadGroupingKey] = thread
325                continue
326            }
327
328            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
329            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
330            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
331            let mergedMessageCount = max(
332                existing.messageCount ?? existing.threadRootMessageIDs.count,
333                thread.messageCount ?? thread.threadRootMessageIDs.count,
334                mergedRootMessageIDs.count
335            )
336
337            grouped[thread.threadGroupingKey] = InboxThreadSummary(
338                rootEmailID: latest.rootEmailID,
339                rootMessageID: latest.rootMessageID,
340                threadRootEmailIDs: mergedRootEmailIDs,
341                threadRootMessageIDs: mergedRootMessageIDs,
342                listID: latest.listID,
343                listRID: latest.listRID,
344                listName: latest.listName,
345                listOwner: latest.listOwner,
346                subject: latest.subject,
347                latestSender: latest.latestSender,
348                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
349                messageCount: mergedMessageCount,
350                repo: latest.repo ?? existing.repo,
351                containsPatch: latest.containsPatch || existing.containsPatch,
352                isUnread: latest.isUnread || existing.isUnread
353            )
354        }
355
356        return grouped.values.sorted { lhs, rhs in
357            if lhs.lastActivityAt == rhs.lastActivityAt {
358                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
359            }
360            return lhs.lastActivityAt > rhs.lastActivityAt
361        }
362    }
363
364    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
365        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
366        let current = threads[index]
367        if !isUnread {
368            threads.remove(at: index)
369            return
370        }
371        threads[index] = InboxThreadSummary(
372            rootEmailID: current.rootEmailID,
373            rootMessageID: current.rootMessageID,
374            threadRootEmailIDs: current.threadRootEmailIDs,
375            threadRootMessageIDs: current.threadRootMessageIDs,
376            listID: current.listID,
377            listRID: current.listRID,
378            listName: current.listName,
379            listOwner: current.listOwner,
380            subject: current.subject,
381            latestSender: current.latestSender,
382            lastActivityAt: current.lastActivityAt,
383            messageCount: current.messageCount,
384            repo: current.repo,
385            containsPatch: current.containsPatch,
386            isUnread: isUnread
387        )
388    }
389
390    private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
391        var seen = Set<String>()
392        return mailingLists.filter { mailingList in
393            seen.insert(mailingList.rid).inserted
394        }
395    }
396
397    nonisolated static func deriveRepositoryName(from listName: String) -> String? {
398        let separators = ["-devel", "-patches", "-dev", ".patches"]
399        for separator in separators where listName.hasSuffix(separator) {
400            return String(listName.dropLast(separator.count))
401        }
402        return nil
403    }
404
405    nonisolated static func filterThreads(_ threads: [InboxThreadSummary], filter: InboxThreadFilter) -> [InboxThreadSummary] {
406        threads.filter { thread in
407            switch filter {
408            case .all:
409                return true
410            case .patches:
411                return thread.containsPatch
412            case .discussions:
413                return !thread.containsPatch
414            }
415        }
416    }
417}