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