Files
andros 2492e6018d Bypass URLSession/CDN cache on relay interaction reads
The simulator was getting stale empty `reactions` arrays from the relay
while curl saw fresh data for the same post URL. Add a `_t=<epoch>`
cache-buster plus an explicit reload policy and `Cache-Control: no-cache`
headers so reaction chips appear as soon as the relay registers them.
2026-04-26 11:04:38 +02:00

166 lines
6.6 KiB
Swift

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=<epoch>` 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
}
}