import Foundation /// Raw (unresolved) node from the relay /replies/ endpoint. public struct RawThreadNode: Sendable { public let postURL: String public let children: [RawThreadNode] public let moods: [OrgSocialMood] } /// Raw thread from relay before posts are resolved to full content. public struct RawThread: Sendable { public let parentURL: String public let parentChainURLs: [String] public let replies: [RawThreadNode] public let moods: [OrgSocialMood] } /// Calls relay endpoints for thread and interaction data. public struct ThreadClient: Sendable { private let session: URLSession public init(session: URLSession = .shared) { self.session = session } // MARK: - Public API /// Fetches interaction counts for a post (reply count, reactions, boosts). public func fetchInteractions(for postURL: String, from relayURL: URL) async throws -> OrgSocialInteractions { let url = try endpoint(relayURL, path: "interactions", postURL: postURL) let json = try await fetchJSON(from: url) guard let data = json["data"] as? [String: Any], let meta = json["meta"] as? [String: Any] else { throw RelayError.invalidResponse } let replyURLs = (data["replies"] as? [String]) ?? [] let boostURLs = (data["boosts"] as? [String]) ?? [] let replyCount = (meta["total_replies"] as? Int) ?? replyURLs.count let reactionCount = (meta["total_reactions"] as? Int) ?? 0 let boostCount = (meta["total_boosts"] as? Int) ?? boostURLs.count // Group raw {post, emoji} pairs by emoji, sorted by count descending let reactionsRaw = (data["reactions"] as? [[String: Any]]) ?? [] var emojiMap: [String: [String]] = [:] for r in reactionsRaw { if let emoji = r["emoji"] as? String, let post = r["post"] as? String { emojiMap[emoji, default: []].append(post) } } let reactions = emojiMap .map { OrgSocialMood(emoji: $0.key, posts: $0.value) } .sorted { $0.posts.count > $1.posts.count } return OrgSocialInteractions( replyCount: replyCount, reactionCount: reactionCount, boostCount: boostCount, replyURLs: replyURLs, boostURLs: boostURLs, reactions: reactions ) } /// Returns the URL of the root post in the thread containing `postURL`. /// /// The relay's `/replies/` response includes a `parentChain` ordered oldest-first. /// The root is the first element; if the chain is empty, the post itself is the root. public func fetchRootPostURL(for postURL: String, from relayURL: URL) async throws -> String { let raw = try await fetchRawThread(for: postURL, from: relayURL) return raw.parentChainURLs.first ?? raw.parentURL } /// Fetches the raw (unresolved) thread tree from the relay. /// /// Timeline rows use this directly to get both the reply tree and aggregated /// reactions (`meta.moods`) in a single request, replacing `/interactions/`. public func fetchRawThread(for postURL: String, from relayURL: URL) async throws -> RawThread { let url = try endpoint(relayURL, path: "replies", postURL: postURL) let json = try await fetchJSON(from: url) guard let meta = json["meta"] as? [String: Any], let parentURL = meta["parent"] as? String else { throw RelayError.invalidResponse } let parentChainURLs = (meta["parentChain"] as? [String]) ?? [] let moodsRaw = (meta["moods"] as? [[String: Any]]) ?? [] let moods = parseMoods(moodsRaw) let rawReplies = (json["data"] as? [[String: Any]]) ?? [] let replies = rawReplies.compactMap { parseNode($0) } return RawThread( parentURL: parentURL, parentChainURLs: parentChainURLs, replies: replies, moods: moods ) } // MARK: - Parsing private func parseNode(_ dict: [String: Any]) -> RawThreadNode? { guard let postURL = dict["post"] as? String else { return nil } let childDicts = (dict["children"] as? [[String: Any]]) ?? [] let moodDicts = (dict["moods"] as? [[String: Any]]) ?? [] return RawThreadNode( postURL: postURL, children: childDicts.compactMap { parseNode($0) }, moods: parseMoods(moodDicts) ) } private func parseMoods(_ dicts: [[String: Any]]) -> [OrgSocialMood] { dicts.compactMap { d in guard let emoji = d["emoji"] as? String else { return nil } let posts = (d["posts"] as? [String]) ?? [] return OrgSocialMood(emoji: emoji, posts: posts) } } // MARK: - Helpers private func endpoint(_ relayURL: URL, path: String, postURL: String) throws -> URL { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) // .urlQueryAllowed keeps `+` unencoded (it would be decoded as a space by servers). // Build a charset that forces `+` to be percent-encoded as %2B. var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "+") let encoded = postURL.addingPercentEncoding(withAllowedCharacters: allowed) ?? postURL // `_t=` busts both URLCache and any CDN edge cache: without it // the simulator was getting stale empty `reactions` arrays from the // relay while curl saw fresh data for the same post URL. let bust = Int(Date().timeIntervalSince1970) guard let url = URL(string: "\(base)/\(path)/?post=\(encoded)&_t=\(bust)") else { throw RelayError.invalidResponse } return url } private func fetchJSON(from url: URL) async throws -> [String: Any] { var request = URLRequest(url: url) request.timeoutInterval = 10 request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") request.setValue("no-cache", forHTTPHeaderField: "Pragma") let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { throw RelayError.networkError(underlying: error.localizedDescription) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw RelayError.invalidResponse } return json } }