krz/hutch

an ios client for sourcehut

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

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