krz/hutch

an ios client for sourcehut

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

v2.3.0: 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        } catch {
140            inboxListLogger.error("Inbox request failed")
141            self.error = "Failed to load inbox"
142        }
143    }
144
145    func markThreadRead(_ thread: InboxThreadSummary) {
146        let viewedAt = max(Date(), thread.lastActivityAt)
147        InboxReadStateStore.markViewed(viewedAt, for: thread.id)
148        threads.removeAll { $0.id == thread.id }
149    }
150
151    func markThreadUnread(_ thread: InboxThreadSummary) {
152        InboxReadStateStore.markUnread(for: thread.id)
153        updateThread(thread, isUnread: true)
154    }
155
156    func toggleThreadReadState(_ thread: InboxThreadSummary) {
157        if thread.isUnread {
158            markThreadRead(thread)
159        } else {
160            markThreadUnread(thread)
161        }
162    }
163
164    func thread(withID id: InboxThreadSummary.ID) -> InboxThreadSummary? {
165        threads.first(where: { $0.id == id })
166    }
167
168    var filteredThreads: [InboxThreadSummary] {
169        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
170        guard !q.isEmpty else { return threads }
171        return threads.filter {
172            $0.displaySubject.lowercased().contains(q) ||
173            $0.listName.lowercased().contains(q) ||
174            $0.latestSender.canonicalName.lowercased().contains(q)
175        }
176    }
177
178    private func fetchSubscriptions() async throws -> [InboxActivitySubscription] {
179        var subscriptions: [InboxActivitySubscription] = []
180        var cursor: String?
181
182        while true {
183            var variables: [String: any Sendable] = [:]
184            if let cursor {
185                variables["cursor"] = cursor
186            }
187
188            let response = try await client.execute(
189                service: .lists,
190                query: Self.subscriptionsQuery,
191                variables: variables.isEmpty ? nil : variables,
192                responseType: InboxSubscriptionsResponse.self
193            )
194
195            subscriptions.append(contentsOf: response.subscriptions.results)
196            guard let nextCursor = response.subscriptions.cursor else {
197                break
198            }
199            cursor = nextCursor
200        }
201
202        return subscriptions
203    }
204
205    private func fetchThreads(for mailingLists: [InboxMailingListReference]) async throws -> [InboxThreadSummary] {
206        guard !mailingLists.isEmpty else { return [] }
207
208        var summaries: [InboxThreadSummary] = []
209        var startIndex = mailingLists.startIndex
210        var failureMessages: [String] = []
211
212        while startIndex < mailingLists.endIndex {
213            let endIndex = mailingLists.index(
214                startIndex,
215                offsetBy: listFetchConcurrencyLimit,
216                limitedBy: mailingLists.endIndex
217            ) ?? mailingLists.endIndex
218            let batch = Array(mailingLists[startIndex..<endIndex])
219
220            let batchResult = await withTaskGroup(of: ([InboxThreadSummary], String?).self) { group in
221                for mailingList in batch {
222                    group.addTask {
223                        do {
224                            return (try await self.fetchThreads(for: mailingList), nil)
225                        } catch {
226                            return ([], "rid=\(mailingList.rid) error=\(error.localizedDescription)")
227                        }
228                    }
229                }
230
231                var batchSummaries: [InboxThreadSummary] = []
232                var batchFailures: [String] = []
233                for await result in group {
234                    batchSummaries.append(contentsOf: result.0)
235                    if let failure = result.1 {
236                        batchFailures.append(failure)
237                    }
238                }
239                return (batchSummaries, batchFailures)
240            }
241
242            summaries.append(contentsOf: batchResult.0)
243            failureMessages.append(contentsOf: batchResult.1)
244            for failure in batchResult.1 {
245                inboxListLogger.error("Inbox thread list request failed: \(failure, privacy: .private)")
246            }
247            startIndex = endIndex
248        }
249
250        if summaries.isEmpty, let firstFailure = failureMessages.first {
251            throw SRHTError.graphQLErrors([GraphQLError(message: firstFailure, locations: nil)])
252        }
253
254        return deduplicateThreads(summaries)
255    }
256
257    private func fetchThreads(for mailingList: InboxMailingListReference) async throws -> [InboxThreadSummary] {
258        let response = try await client.execute(
259            service: .lists,
260            query: Self.listThreadsQuery,
261            variables: ["rid": mailingList.rid],
262            responseType: InboxListThreadsResponse.self
263        )
264
265        return response.list.threads.results.prefix(listThreadFetchLimit).map { thread in
266            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())"
267            let isUnread = InboxReadStateStore.isUnread(threadID: groupingKey, lastActivityAt: thread.updated)
268            return InboxThreadSummary(
269                rootEmailID: thread.root.id,
270                rootMessageID: thread.root.messageID,
271                threadRootEmailIDs: [thread.root.id],
272                threadRootMessageIDs: [thread.root.messageID],
273                listID: mailingList.id,
274                listRID: mailingList.rid,
275                listName: mailingList.name,
276                listOwner: mailingList.owner,
277                subject: thread.subject,
278                latestSender: thread.sender,
279                lastActivityAt: thread.updated,
280                messageCount: thread.replies + 1,
281                repo: Self.deriveRepositoryName(from: mailingList.name),
282                containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
283                isUnread: isUnread
284            )
285        }
286    }
287
288    private func deduplicateThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
289        var grouped: [String: InboxThreadSummary] = [:]
290
291        for thread in threads {
292            guard let existing = grouped[thread.threadGroupingKey] else {
293                grouped[thread.threadGroupingKey] = thread
294                continue
295            }
296
297            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
298            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
299            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
300            let mergedMessageCount = max(
301                existing.messageCount ?? existing.threadRootMessageIDs.count,
302                thread.messageCount ?? thread.threadRootMessageIDs.count,
303                mergedRootMessageIDs.count
304            )
305
306            grouped[thread.threadGroupingKey] = InboxThreadSummary(
307                rootEmailID: latest.rootEmailID,
308                rootMessageID: latest.rootMessageID,
309                threadRootEmailIDs: mergedRootEmailIDs,
310                threadRootMessageIDs: mergedRootMessageIDs,
311                listID: latest.listID,
312                listRID: latest.listRID,
313                listName: latest.listName,
314                listOwner: latest.listOwner,
315                subject: latest.subject,
316                latestSender: latest.latestSender,
317                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
318                messageCount: mergedMessageCount,
319                repo: latest.repo ?? existing.repo,
320                containsPatch: latest.containsPatch || existing.containsPatch,
321                isUnread: latest.isUnread || existing.isUnread
322            )
323        }
324
325        return grouped.values.sorted { lhs, rhs in
326            if lhs.lastActivityAt == rhs.lastActivityAt {
327                return lhs.displaySubject.localizedCaseInsensitiveCompare(rhs.displaySubject) == .orderedAscending
328            }
329            return lhs.lastActivityAt > rhs.lastActivityAt
330        }
331    }
332
333    private func updateThread(_ thread: InboxThreadSummary, isUnread: Bool) {
334        guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
335        let current = threads[index]
336        if !isUnread {
337            threads.remove(at: index)
338            return
339        }
340        threads[index] = InboxThreadSummary(
341            rootEmailID: current.rootEmailID,
342            rootMessageID: current.rootMessageID,
343            threadRootEmailIDs: current.threadRootEmailIDs,
344            threadRootMessageIDs: current.threadRootMessageIDs,
345            listID: current.listID,
346            listRID: current.listRID,
347            listName: current.listName,
348            listOwner: current.listOwner,
349            subject: current.subject,
350            latestSender: current.latestSender,
351            lastActivityAt: current.lastActivityAt,
352            messageCount: current.messageCount,
353            repo: current.repo,
354            containsPatch: current.containsPatch,
355            isUnread: isUnread
356        )
357    }
358
359    private func deduplicateMailingLists(_ mailingLists: [InboxMailingListReference]) -> [InboxMailingListReference] {
360        var seen = Set<String>()
361        return mailingLists.filter { mailingList in
362            seen.insert(mailingList.rid).inserted
363        }
364    }
365
366    nonisolated static func deriveRepositoryName(from listName: String) -> String? {
367        let separators = ["-devel", "-patches", "-dev", ".patches"]
368        for separator in separators where listName.hasSuffix(separator) {
369            return String(listName.dropLast(separator.count))
370        }
371        return nil
372    }
373}