krz/hutch

an ios client for sourcehut

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

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