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}