2492e6018d
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.
166 lines
6.6 KiB
Swift
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
|
|
}
|
|
}
|