krz/hutch

an ios client for sourcehut

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

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