krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.15.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}