import Foundation /// Fetches a full conversation thread, resolving all post URLs to `OrgSocialPost` objects. /// /// Groups posts by feed URL so each unique `social.org` is downloaded only once. public struct ThreadFetcher: Sendable { private let threadClient: ThreadClient private let profileFetcher: ProfileFetcher public init(session: URLSession = .shared) { self.threadClient = ThreadClient(session: session) self.profileFetcher = ProfileFetcher(session: session) } /// Fetches the full thread for a post URL (e.g. `https://andros.dev/social.org#2025-…`). /// /// - Parameters: /// - postURL: The canonical post URL including `#timestamp`. /// - relayURL: Base URL of the relay. /// - Returns: A fully-resolved `OrgSocialThread`. public func fetchThread(for postURL: String, from relayURL: URL) async throws -> OrgSocialThread { let raw = try await threadClient.fetchRawThread(for: postURL, from: relayURL) // Collect all unique feed URLs we need to download var allPostURLs: [String] = [raw.parentURL] + raw.parentChainURLs collectPostURLs(from: raw.replies, into: &allPostURLs) // Group by feed URL (everything before the `#`) let feedURLs = Set(allPostURLs.compactMap { feedURL(from: $0) }) // Fetch all feeds concurrently; skip feeds that are unavailable let profiles = await withTaskGroup(of: (String, OrgSocialProfile)?.self) { group in for feedURLStr in feedURLs { group.addTask { guard let url = URL(string: feedURLStr) else { return nil } guard let profile = try? await self.profileFetcher.fetch(from: url) else { return nil } return (feedURLStr, profile) } } var result: [String: OrgSocialProfile] = [:] for await item in group { if let (key, profile) = item { result[key] = profile } } return result } // Build a lookup: normalizedPostURL → OrgSocialPost (enriched with author metadata) // Store both colon ("+01:00") and no-colon ("+0100") forms since the relay // and the parser may use different timezone formats. var lookup: [String: OrgSocialPost] = [:] for (feedURLStr, profile) in profiles { let feedURL = URL(string: feedURLStr) for var post in profile.posts { post.authorNick = profile.nick post.authorURL = feedURL post.authorAvatar = profile.avatar post.feedURL = feedURL let canonical = "\(feedURLStr)#\(post.timestamp)" let compact = "\(feedURLStr)#\(compactTimezone(post.timestamp))" lookup[canonical] = post lookup[compact] = post } } // Resolve focal post guard let focalPost = lookup[raw.parentURL] else { throw RelayError.invalidResponse } // Relay returns parentChain oldest-first (root at index 0) let parentChain = raw.parentChainURLs.compactMap { lookup[$0] } // Resolve reply tree let replies = raw.replies.map { resolveNode($0, lookup: lookup) } return OrgSocialThread( focalPost: focalPost, parentChain: Array(parentChain), replies: replies, moods: raw.moods ) } // MARK: - Helpers /// Removes the colon from the timezone offset: `+01:00` → `+0100`. private func compactTimezone(_ ts: String) -> String { guard let range = ts.range(of: #"([+-]\d{2}):(\d{2})$"#, options: .regularExpression) else { return ts } return ts.replacingCharacters(in: range, with: ts[range].replacingOccurrences(of: ":", with: "")) } private func feedURL(from postURL: String) -> String? { postURL.components(separatedBy: "#").first } private func collectPostURLs(from nodes: [RawThreadNode], into urls: inout [String]) { for node in nodes { urls.append(node.postURL) collectPostURLs(from: node.children, into: &urls) } } private func resolveNode(_ node: RawThreadNode, lookup: [String: OrgSocialPost]) -> OrgSocialThreadNode { let post = lookup[node.postURL] ?? fallbackPost(for: node.postURL) let children = node.children.map { resolveNode($0, lookup: lookup) } return OrgSocialThreadNode(post: post, children: children, moods: node.moods) } /// Creates a minimal post for URLs we couldn't resolve (feed unavailable). private func fallbackPost(for postURL: String) -> OrgSocialPost { let parts = postURL.components(separatedBy: "#") let feedURLStr = parts.first ?? postURL let timestamp = parts.count > 1 ? parts[1] : "" let feedURL = URL(string: feedURLStr) var post = OrgSocialPost(timestamp: timestamp, date: Date(), text: "") post.authorURL = feedURL post.feedURL = feedURL return post } }