krz/hutch

an ios client for sourcehut

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

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