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