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