krz/hutch

an ios client for sourcehut

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

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