krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2: Hutch/Views/Inbox/ThreadViewModel.swift · raw
1import Foundation
2import os
3
4private let inboxLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Inbox")
5
6private struct InboxThreadDetailResponse: Decodable, Sendable {
7 let list: InboxThreadDetailList?
8}
9
10private struct InboxThreadDetailList: Decodable, Sendable {
11 let threads: InboxThreadPayloadPage?
12}
13
14private struct InboxThreadLookupResponse: Decodable, Sendable {
15 let list: InboxThreadLookupList?
16}
17
18private struct InboxThreadLookupList: Decodable, Sendable {
19 let message: InboxThreadLookupMessage?
20}
21
22private struct InboxThreadLookupMessage: Decodable, Sendable {
23 let thread: InboxThreadPayloadDetail?
24}
25
26private struct InboxThreadPayloadDetail: Decodable, Sendable {
27 let subject: String?
28 let updated: Date?
29 let replies: Int?
30 let sender: Entity?
31 let list: InboxMailingListReference?
32 let root: InboxThreadMessagePayload?
33 let descendants: InboxThreadMessagesPage?
34}
35
36private struct InboxThreadPayloadPage: Decodable, Sendable {
37 let results: [InboxThreadPayloadDetail]
38 let cursor: String?
39}
40
41private struct InboxThreadMessagesPage: Decodable, Sendable {
42 let results: [InboxThreadMessagePayload]?
43 let cursor: String?
44}
45
46private struct InboxThreadMessagePayload: Decodable, Sendable {
47 let id: Int?
48 let sender: Entity?
49 let received: Date?
50 let date: Date?
51 let subject: String?
52 let messageID: String?
53 let body: String?
54 let rawMessage: URL?
55 let patch: InboxPatchPreview?
56}
57
58@Observable
59@MainActor
60final class ThreadViewModel {
61 private(set) var thread: InboxThreadDetail?
62 private(set) var isLoading = false
63 var error: String?
64 var partialWarning: String?
65 var composeDraft: MailComposeDraft?
66
67 private let summary: InboxThreadSummary
68 private let client: SRHTClient
69
70 private static let threadDetailQuery = """
71 query inboxThreadDetail($rid: ID!, $cursor: Cursor, $descCursor: Cursor) {
72 list(rid: $rid) {
73 threads(cursor: $cursor) {
74 results {
75 subject
76 updated
77 replies
78 sender { canonicalName }
79 list {
80 id
81 rid
82 name
83 owner { canonicalName }
84 }
85 root {
86 id
87 sender { canonicalName }
88 received
89 date
90 subject
91 messageID
92 body
93 rawMessage
94 patch { subject }
95 }
96 descendants(cursor: $descCursor) {
97 results {
98 id
99 sender { canonicalName }
100 received
101 date
102 subject
103 messageID
104 body
105 rawMessage
106 patch { subject }
107 }
108 cursor
109 }
110 }
111 cursor
112 }
113 }
114 }
115 """
116
117 private static let threadByMessageIDQuery = """
118 query inboxThreadByMessageID($rid: ID!, $messageID: String!, $descCursor: Cursor) {
119 list(rid: $rid) {
120 message(messageID: $messageID) {
121 thread {
122 subject
123 updated
124 replies
125 sender { canonicalName }
126 list {
127 id
128 rid
129 name
130 owner { canonicalName }
131 }
132 root {
133 id
134 sender { canonicalName }
135 received
136 date
137 subject
138 messageID
139 body
140 rawMessage
141 patch { subject }
142 }
143 descendants(cursor: $descCursor) {
144 results {
145 id
146 sender { canonicalName }
147 received
148 date
149 subject
150 messageID
151 body
152 rawMessage
153 patch { subject }
154 }
155 cursor
156 }
157 }
158 }
159 }
160 }
161 """
162
163 init(summary: InboxThreadSummary, client: SRHTClient) {
164 self.summary = summary
165 self.client = client
166 }
167
168 func loadThread() async {
169 guard !isLoading else { return }
170 isLoading = true
171 error = nil
172 partialWarning = nil
173 defer { isLoading = false }
174
175 inboxLogger.debug("Opening inbox thread: \(self.summary.debugIdentifierSummary, privacy: .public)")
176
177 do {
178 let threadPayloads = try await fetchThreadPayloads()
179
180 guard !threadPayloads.isEmpty else {
181 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread is no longer available.", locations: nil)])
182 }
183
184 let listReference = threadPayloads.lazy.compactMap(\.list).first ?? InboxMailingListReference(
185 id: summary.listID,
186 rid: summary.listRID,
187 name: summary.listName,
188 owner: summary.listOwner
189 )
190 var messagesByID: [Int: InboxMessage] = [:]
191
192 var hadPartialReplyFailure = false
193
194 for payload in threadPayloads {
195 guard let rootMessage = Self.message(from: payload.root, fallbackID: summary.rootEmailID) else {
196 continue
197 }
198 messagesByID[rootMessage.id] = rootMessage
199
200 do {
201 let descendantMessages = try await fetchAllDescendantMessages(
202 initialPayload: payload,
203 candidateMessageIDs: Self.messageIDCandidates(from: payload.root?.messageID ?? summary.rootMessageID)
204 )
205 for message in descendantMessages {
206 messagesByID[message.id] = message
207 }
208 } catch {
209 hadPartialReplyFailure = true
210 inboxLogger.error(
211 "Inbox thread descendants failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)"
212 )
213 }
214 }
215
216 let messages = messagesByID.values.sorted { $0.date < $1.date }
217 guard !messages.isEmpty else {
218 throw SRHTError.graphQLErrors([GraphQLError(message: "Thread root message is unavailable.", locations: nil)])
219 }
220
221 let latestPayload = threadPayloads.max(by: { ($0.updated ?? .distantPast) < ($1.updated ?? .distantPast) }) ?? threadPayloads[0]
222 thread = InboxThreadDetail(
223 id: summary.id,
224 rootEmailID: summary.rootEmailID,
225 rootMessageID: summary.rootMessageID,
226 subject: latestPayload.subject ?? summary.subject,
227 author: latestPayload.sender ?? summary.latestSender,
228 lastActivityAt: latestPayload.updated ?? summary.lastActivityAt,
229 mailto: nil,
230 listID: listReference.id,
231 listRID: listReference.rid,
232 listName: listReference.name,
233 listOwner: listReference.owner,
234 messageCount: max(messages.count, summary.messageCount ?? 0),
235 messages: messages
236 )
237 if hadPartialReplyFailure {
238 partialWarning = "Some replies could not be loaded."
239 }
240 } catch {
241 if thread == nil {
242 self.error = "Failed to load thread"
243 } else {
244 self.error = error.localizedDescription
245 }
246 inboxLogger.error("Inbox thread detail failed for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
247 }
248 }
249
250 private func fetchThreadPayloads() async throws -> [InboxThreadPayloadDetail] {
251 var payloads: [InboxThreadPayloadDetail] = []
252 var seenRoots = Set<String>()
253
254 for rootMessageID in summary.threadRootMessageIDs {
255 guard !seenRoots.contains(rootMessageID) else { continue }
256 seenRoots.insert(rootMessageID)
257 if let payload = try await fetchThreadPayload(rootMessageID: rootMessageID) {
258 payloads.append(payload)
259 }
260 }
261
262 if payloads.isEmpty, let fallback = try await fetchThreadPayload(rootMessageID: summary.rootMessageID) {
263 payloads.append(fallback)
264 }
265
266 return payloads
267 }
268
269 private func fetchThreadPayload(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
270 if let messageMatchedThread = try await fetchThreadByMessageID(rootMessageID: rootMessageID) {
271 return messageMatchedThread
272 }
273 return try await scanThreadPages(targetRootMessageID: rootMessageID)
274 }
275
276 private func fetchThreadByMessageID(rootMessageID: String) async throws -> InboxThreadPayloadDetail? {
277 let candidateMessageIDs = Self.messageIDCandidates(from: rootMessageID)
278 inboxLogger.debug(
279 "Inbox thread lookup IDs: subject=\(self.summary.subject, privacy: .public) rootEmailID=\(self.summary.rootEmailID, privacy: .public) rootMessageID=\(rootMessageID, privacy: .public) candidates=\(candidateMessageIDs.joined(separator: ", "), privacy: .public)"
280 )
281
282 var lastLookupError: Error?
283
284 for messageID in candidateMessageIDs {
285 inboxLogger.debug(
286 "Inbox thread detail lookup request: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public)"
287 )
288
289 do {
290 let response: InboxThreadLookupResponse = try await Self.executeGraphQLRequest(
291 client: client,
292 query: Self.threadByMessageIDQuery,
293 variables: [
294 "rid": self.summary.listRID,
295 "messageID": messageID,
296 "descCursor": nil as String?
297 ]
298 )
299
300 if let thread = response.list?.message?.thread {
301 return thread
302 }
303 } catch let error as SRHTError {
304 switch error {
305 case .graphQLErrors(let errors):
306 let combinedMessage = errors.map(\.message).joined(separator: " | ")
307 inboxLogger.error(
308 "Inbox thread message lookup failed: rid=\(self.summary.listRID, privacy: .public) messageID=\(messageID, privacy: .public) errors=\(combinedMessage, privacy: .public)"
309 )
310 if errors.allSatisfy({ $0.message.localizedCaseInsensitiveContains("no rows in result set") }) {
311 lastLookupError = error
312 continue
313 }
314 throw error
315 default:
316 throw error
317 }
318 }
319 }
320
321 if let lastLookupError {
322 inboxLogger.debug(
323 "Inbox thread message lookup exhausted candidates for \(self.summary.debugIdentifierSummary, privacy: .public): \(lastLookupError.localizedDescription, privacy: .public)"
324 )
325 }
326 return nil
327 }
328
329 private func scanThreadPages(targetRootMessageID: String) async throws -> InboxThreadPayloadDetail? {
330 var threadCursor: String?
331
332 while true {
333 var variables: [String: any Sendable] = ["rid": summary.listRID]
334 if let threadCursor {
335 variables["cursor"] = threadCursor
336 }
337
338 let response: InboxThreadDetailResponse
339 do {
340 response = try await Self.executeGraphQLRequest(
341 client: client,
342 query: Self.threadDetailQuery,
343 variables: {
344 var variables = variables
345 variables["descCursor"] = nil as String?
346 return variables
347 }()
348 )
349 } catch {
350 if Self.isRecoverableNoRows(error) {
351 inboxLogger.error("Inbox thread page scan recoverable miss for \(self.summary.debugIdentifierSummary, privacy: .public): \(error.localizedDescription, privacy: .public)")
352 return nil
353 }
354 throw error
355 }
356
357 guard let threadPage = response.list?.threads else {
358 return nil
359 }
360
361 let candidates = threadPage.results.map { payload in
362 "subject=\(payload.subject ?? "<nil>") rootEmailID=\(payload.root?.id.map(String.init) ?? "<nil>") rootMessageID=\(payload.root?.messageID ?? "<nil>")"
363 }.joined(separator: " | ")
364 inboxLogger.debug("Inbox thread detail page candidates: \(candidates, privacy: .public)")
365
366 if let matchedThread = threadPage.results.first(where: {
367 $0.root?.messageID == targetRootMessageID ||
368 $0.root?.id == summary.rootEmailID ||
369 $0.root?.subject == summary.subject
370 }) {
371 return matchedThread
372 }
373
374 guard let nextCursor = threadPage.cursor else {
375 return nil
376 }
377 threadCursor = nextCursor
378 }
379 }
380
381 private func fetchAllDescendantMessages(
382 initialPayload: InboxThreadPayloadDetail,
383 candidateMessageIDs: [String]
384 ) async throws -> [InboxMessage] {
385 var messagesByID: [Int: InboxMessage] = [:]
386
387 for payload in initialPayload.descendants?.results ?? [] {
388 if let message = Self.message(from: payload, fallbackID: nil) {
389 messagesByID[message.id] = message
390 }
391 }
392
393 var descendantCursor = initialPayload.descendants?.cursor
394 while let currentCursor = descendantCursor {
395 guard let page = try await fetchDescendantPage(
396 cursor: currentCursor,
397 candidateMessageIDs: candidateMessageIDs
398 ) else {
399 break
400 }
401
402 for payload in page.results ?? [] {
403 if let message = Self.message(from: payload, fallbackID: nil) {
404 messagesByID[message.id] = message
405 }
406 }
407 descendantCursor = page.cursor
408 }
409
410 return messagesByID.values.sorted { $0.date < $1.date }
411 }
412
413 private func fetchDescendantPage(
414 cursor: String,
415 candidateMessageIDs: [String]
416 ) async throws -> InboxThreadMessagesPage? {
417 for messageID in candidateMessageIDs {
418 let response: InboxThreadLookupResponse
419 do {
420 response = try await Self.executeGraphQLRequest(
421 client: client,
422 query: Self.threadByMessageIDQuery,
423 variables: [
424 "rid": summary.listRID,
425 "messageID": messageID,
426 "descCursor": cursor
427 ]
428 )
429 } catch {
430 if Self.isRecoverableNoRows(error) {
431 inboxLogger.error(
432 "Inbox descendant page recoverable miss: thread=\(self.summary.debugIdentifierSummary, privacy: .public) messageID=\(messageID, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
433 )
434 continue
435 }
436 throw error
437 }
438
439 if let descendants = response.list?.message?.thread?.descendants {
440 return descendants
441 }
442 }
443
444 return nil
445 }
446
447 func prepareReply() {
448 guard let thread else {
449 error = "This thread is not ready to reply to yet."
450 return
451 }
452 inboxLogger.debug(
453 "Preparing inbox reply: subject=\(thread.subject, privacy: .public) listRID=\(thread.listRID, privacy: .public) rootMessageID=\(thread.rootMessageID, privacy: .public) recipient=\(thread.replyRecipient, privacy: .public) senderIdentity=system-mail-account"
454 )
455 composeDraft = MailComposeDraft(
456 recipients: [thread.replyRecipient],
457 ccRecipients: [],
458 subject: thread.replySubject,
459 body: ""
460 )
461 }
462
463 func dismissReply() {
464 composeDraft = nil
465 }
466
467 private static func message(from payload: InboxThreadMessagePayload?, fallbackID: Int?) -> InboxMessage? {
468 guard let payload else { return nil }
469 guard let id = payload.id ?? fallbackID,
470 let author = payload.sender,
471 let date = payload.date ?? payload.received,
472 let subject = payload.subject,
473 let body = payload.body else {
474 return nil
475 }
476
477 let normalizedIdentity = normalizedSenderIdentity(from: body, fallbackAuthor: author)
478 let displayBody = sanitizedDisplayBody(from: body)
479 let contentBlocks = segmentMessageBody(displayBody, isPatch: payload.patch != nil)
480
481 return InboxMessage(
482 id: id,
483 author: author,
484 date: date,
485 subject: subject,
486 body: body,
487 senderDisplayName: normalizedIdentity.displayName,
488 senderEmailAddress: normalizedIdentity.emailAddress,
489 isPatch: payload.patch != nil,
490 contentBlocks: contentBlocks,
491 rawMessageURL: payload.rawMessage
492 )
493 }
494
495 nonisolated static func mailComposeDraft(from mailto: String) -> MailComposeDraft? {
496 guard let components = URLComponents(string: mailto),
497 components.scheme?.lowercased() == "mailto" else {
498 return nil
499 }
500
501 let recipients = components.path
502 .split(separator: ",")
503 .map { String($0) }
504 .filter { !$0.isEmpty }
505 let queryItems = components.queryItems ?? []
506 let ccRecipients = queryItems
507 .first(where: { $0.name.caseInsensitiveCompare("cc") == .orderedSame })?
508 .value?
509 .split(separator: ",")
510 .map(String.init) ?? []
511 let subject = queryItems
512 .first(where: { $0.name.caseInsensitiveCompare("subject") == .orderedSame })?
513 .value ?? ""
514 let body = queryItems
515 .first(where: { $0.name.caseInsensitiveCompare("body") == .orderedSame })?
516 .value ?? ""
517
518 return MailComposeDraft(
519 recipients: recipients,
520 ccRecipients: ccRecipients,
521 subject: subject,
522 body: body
523 )
524 }
525
526 private static func messageIDCandidates(from messageID: String) -> [String] {
527 let trimmedMessageID = messageID.trimmingCharacters(in: .whitespacesAndNewlines)
528 guard !trimmedMessageID.isEmpty else { return [] }
529
530 if trimmedMessageID.hasPrefix("<"), trimmedMessageID.hasSuffix(">") {
531 return [trimmedMessageID, String(trimmedMessageID.dropFirst().dropLast())]
532 }
533
534 return [trimmedMessageID, "<\(trimmedMessageID)>"]
535 }
536
537 private static func normalizedSenderIdentity(from body: String, fallbackAuthor: Entity) -> (displayName: String, emailAddress: String?) {
538 guard let fromLine = leadingHeaderValue(named: "From", in: body) else {
539 return fallbackSenderIdentity(from: fallbackAuthor)
540 }
541
542 let trimmedFromLine = fromLine.trimmingCharacters(in: .whitespacesAndNewlines)
543 if let start = trimmedFromLine.lastIndex(of: "<"),
544 let end = trimmedFromLine.lastIndex(of: ">"),
545 start < end {
546 let email = String(trimmedFromLine[trimmedFromLine.index(after: start)..<end]).trimmingCharacters(in: .whitespaces)
547 let name = String(trimmedFromLine[..<start]).trimmingCharacters(in: .whitespacesAndNewlines)
548 if !name.isEmpty {
549 return (name, email.isEmpty ? nil : email)
550 }
551 return (email.isEmpty ? trimmedFromLine : email, email.isEmpty ? nil : email)
552 }
553
554 if trimmedFromLine.contains("@") {
555 return (trimmedFromLine, trimmedFromLine)
556 }
557
558 return (trimmedFromLine, nil)
559 }
560
561 private static func fallbackSenderIdentity(from author: Entity) -> (displayName: String, emailAddress: String?) {
562 let canonicalName = author.canonicalName.trimmingCharacters(in: .whitespacesAndNewlines)
563 if canonicalName.contains("@") {
564 return (canonicalName, canonicalName)
565 }
566 if canonicalName.hasPrefix("~") {
567 return (String(canonicalName.dropFirst()), nil)
568 }
569 return (canonicalName, nil)
570 }
571
572 private static func sanitizedDisplayBody(from body: String) -> String {
573 let normalizedBody = normalizeLineEndings(in: body)
574 let lines = normalizedBody.components(separatedBy: "\n")
575 let headerPrefixes = ["From:", "Date:", "To:", "Cc:", "Subject:"]
576 var headerCount = 0
577 var blankLineIndex: Int?
578
579 for (index, line) in lines.prefix(12).enumerated() {
580 if line.isEmpty {
581 blankLineIndex = index
582 break
583 }
584 if headerPrefixes.contains(where: { line.hasPrefix($0) }) {
585 headerCount += 1
586 } else if headerCount > 0 {
587 break
588 }
589 }
590
591 guard headerCount >= 2, let blankLineIndex else {
592 return stripLeadingFromLineIfPresent(in: normalizedBody)
593 }
594
595 return lines.dropFirst(blankLineIndex + 1).joined(separator: "\n")
596 }
597
598 nonisolated static func segmentMessageBodyForTesting(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
599 segmentMessageBody(body, isPatch: isPatch)
600 }
601
602 private nonisolated static func segmentMessageBody(_ body: String, isPatch: Bool) -> [InboxMessageContentBlock] {
603 guard isPatch else {
604 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
605 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
606 }
607
608 let normalizedBody = normalizeLineEndings(in: body)
609 let lines = normalizedBody.components(separatedBy: "\n")
610 guard let diffStartIndex = actualDiffStartIndex(in: lines) else {
611 let trimmedBody = normalizedBody.trimmingCharacters(in: .whitespacesAndNewlines)
612 return trimmedBody.isEmpty ? [] : [.plainText(trimmedBody)]
613 }
614
615 var blocks: [InboxMessageContentBlock] = []
616 let leadingPlainText = lines[..<diffStartIndex]
617 .joined(separator: "\n")
618 .trimmingCharacters(in: .whitespacesAndNewlines)
619 if !leadingPlainText.isEmpty {
620 blocks.append(.plainText(leadingPlainText))
621 }
622
623 let remainingLines = Array(lines[diffStartIndex...])
624 let signatureIndex = remainingLines.firstIndex(where: isEmailSignatureSeparator)
625
626 let diffLines: ArraySlice<String>
627 let trailingPlainText: String
628 if let signatureIndex {
629 diffLines = remainingLines[..<signatureIndex]
630 trailingPlainText = remainingLines[signatureIndex...]
631 .joined(separator: "\n")
632 .trimmingCharacters(in: .whitespacesAndNewlines)
633 } else {
634 diffLines = remainingLines[...]
635 trailingPlainText = ""
636 }
637
638 let diff = diffLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
639 if !diff.isEmpty {
640 blocks.append(.diff(diff))
641 }
642
643 if !trailingPlainText.isEmpty {
644 blocks.append(.plainText(trailingPlainText))
645 }
646 return blocks
647 }
648
649 private nonisolated static func actualDiffStartIndex(in lines: [String]) -> Int? {
650 if let explicitDiffIndex = lines.firstIndex(where: { $0.hasPrefix("diff --git ") }) {
651 return explicitDiffIndex
652 }
653
654 for index in lines.indices {
655 let line = lines[index]
656 guard line.hasPrefix("--- ") else { continue }
657 let nextIndex = lines.index(after: index)
658 guard nextIndex < lines.endIndex else { continue }
659 let nextLine = lines[nextIndex]
660 guard nextLine.hasPrefix("+++ ") else { continue }
661
662 let oldPath = String(line.dropFirst(4))
663 let newPath = String(nextLine.dropFirst(4))
664 let looksLikeUnifiedDiff = (oldPath.hasPrefix("a/") || oldPath == "/dev/null") &&
665 (newPath.hasPrefix("b/") || newPath == "/dev/null")
666
667 if looksLikeUnifiedDiff {
668 return index
669 }
670 }
671
672 return nil
673 }
674
675 private nonisolated static func isEmailSignatureSeparator(_ line: String) -> Bool {
676 line == "-- " || line == "--"
677 }
678
679 private nonisolated static func normalizeLineEndings(in text: String) -> String {
680 text
681 .replacingOccurrences(of: "\r\n", with: "\n")
682 .replacingOccurrences(of: "\r", with: "\n")
683 }
684
685 private static func stripLeadingFromLineIfPresent(in body: String) -> String {
686 let lines = body.components(separatedBy: "\n")
687 guard let firstLine = lines.first, firstLine.hasPrefix("From:") else {
688 return body
689 }
690
691 var remainingLines = Array(lines.dropFirst())
692 if let nextLine = remainingLines.first, nextLine.isEmpty {
693 remainingLines.removeFirst()
694 }
695 return remainingLines.joined(separator: "\n")
696 }
697
698 private static func leadingHeaderValue(named headerName: String, in body: String) -> String? {
699 let prefix = "\(headerName):"
700 let lines = body.components(separatedBy: .newlines)
701 for line in lines.prefix(12) {
702 if line.isEmpty {
703 break
704 }
705 if line.hasPrefix(prefix) {
706 return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
707 }
708 }
709 return nil
710 }
711
712 private static func executeGraphQLRequest<T: Decodable>(
713 client: SRHTClient,
714 query: String,
715 variables: [String: any Sendable]
716 ) async throws -> T {
717 guard let token = KeychainHelper.loadToken(), !token.isEmpty else {
718 throw SRHTError.unauthorized
719 }
720
721 var request = URLRequest(url: SRHTService.lists.url)
722 request.httpMethod = "POST"
723 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
724 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
725
726 let encoder = JSONEncoder()
727 request.httpBody = try encoder.encode(
728 GraphQLRequestBody(
729 query: query,
730 variables: variables.mapValues { AnyCodable($0) }
731 )
732 )
733
734 let (data, _) = try await URLSession.shared.data(for: request)
735 #if DEBUG
736 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
737 inboxLogger.debug("Inbox thread raw GraphQL response: \(responseBody, privacy: .public)")
738 #endif
739
740 let decoder = JSONDecoder()
741 decoder.dateDecodingStrategy = .srhtFlexible
742 let envelope = try decoder.decode(GraphQLResponse<T>.self, from: data)
743 if let errors = envelope.errors, !errors.isEmpty {
744 throw SRHTError.graphQLErrors(errors)
745 }
746 guard let payload = envelope.data else {
747 throw SRHTError.decodingError(
748 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in thread detail response"))
749 )
750 }
751 return payload
752 }
753
754 private static func isRecoverableNoRows(_ error: Error) -> Bool {
755 guard case let SRHTError.graphQLErrors(errors) = error else {
756 return false
757 }
758 return errors.allSatisfy { $0.message.localizedCaseInsensitiveContains("no rows in result set") }
759 }
760}