import Foundation import Observation import OrgSocialKit /// Serializes all follow / unfollow operations across the app and holds the /// authoritative copy of the user's own feed in memory. /// /// The previous design let each `ProfileView` read the feed, mutate it, and /// upload independently. Tapping Follow on several profiles in quick /// succession caused last-write-wins: B's upload overwrote A's because both /// started from the same pre-A snapshot. Host CDN staleness made it worse: /// a read right after an upload often returned the old content. /// /// This coordinator fixes both: /// - `toggle(url:nick:)` awaits the previous in-flight toggle before /// running, so concurrent taps become a strict sequence. /// - After a successful upload, `cachedContent` is updated from the value /// we just pushed; the next toggle never needs to re-read from CDN. @Observable @MainActor final class FollowCoordinator { static let shared = FollowCoordinator() /// Normalized `absoluteString`s of currently followed URLs, for /// O(1) lookup from the toolbar state. private(set) var followedURLs: Set = [] /// Current follow list, each with an optional display nick. Source of /// truth for any UI that needs to name a followed account (e.g. the /// mention autocomplete). private(set) var follows: [OrgSocialFollow] = [] /// Monotonic counter bumped on every known mutation to the own feed /// (follow toggle, compose, edit, delete, profile edit, pin). Views /// observe this to trigger a refetch, since SwiftUI's TabView keeps /// a tab's .task from firing a second time on subsequent visits. private(set) var feedVersion: UInt = 0 @ObservationIgnored private var cachedContent: String? @ObservationIgnored private var cachedFeedURL: URL? @ObservationIgnored private var didRefresh = false @ObservationIgnored private var inflightTask: Task? private init() {} // MARK: - Refresh /// Fetches the own feed once per session and populates the cache. /// Callers can invoke this repeatedly; subsequent calls no-op. func refreshIfNeeded() async { guard let ownURL = ownFeedURL else { reset() return } if cachedFeedURL == ownURL, didRefresh { return } await forceRefresh() } func forceRefresh() async { guard let ownURL = ownFeedURL else { reset(); return } guard let content = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) else { return } apply(content: content, feedURL: ownURL) didRefresh = true } // MARK: - Query func isFollowing(_ url: URL) -> Bool { followedURLs.contains(Self.normalize(url)) } // MARK: - Mutation /// Toggles follow state for `url`. Operations are strictly serialized: /// a second call awaits the first, preventing lost writes. func toggle(url: URL, nick: String?) async { let previous = inflightTask let task = Task { [weak self] in await previous?.value await self?.performToggle(url: url, nick: nick) } inflightTask = task await task.value } private func performToggle(url: URL, nick: String?) async { guard let ownURL = ownFeedURL, let uploader = UploaderFactory.makeUploader() else { return } if cachedContent == nil || cachedFeedURL != ownURL { guard let initial = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) else { return } apply(content: initial, feedURL: ownURL) didRefresh = true } guard var content = cachedContent else { return } let normalized = Self.normalize(url) let currentlyFollowing = followedURLs.contains(normalized) let pw = ProfileWriter() content = currentlyFollowing ? pw.removeFollow(url: url, from: content) : pw.addFollow(url: url, nick: nick, to: content) do { try await uploader.upload(content: content) apply(content: content, feedURL: ownURL) } catch { // Upload failed; keep the previous cached content untouched. } } // MARK: - Internal state sync /// Call after any external mutation to the own feed (e.g. a post /// published via ComposeView) to keep the cached copy authoritative. func updateCachedContent(_ content: String) { guard let ownURL = ownFeedURL else { return } apply(content: content, feedURL: ownURL) } private func apply(content: String, feedURL: URL) { cachedContent = content cachedFeedURL = feedURL var parsed = OrgSocialParser().parse(content) parsed.feedURL = feedURL followedURLs = Set(parsed.follows.map { Self.normalize($0.url) }) follows = parsed.follows feedVersion &+= 1 } private func reset() { cachedContent = nil cachedFeedURL = nil followedURLs = [] follows = [] didRefresh = false } // MARK: - Helpers private var ownFeedURL: URL? { guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"), let url = URL(string: raw) else { return nil } return url } /// Normalizes a URL for comparison: lowercases scheme/host (DNS is /// case-insensitive) and strips a trailing slash on non-root paths. /// Keeps path case intact because HTTP paths are case-sensitive. static func normalize(_ url: URL) -> String { guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString } comps.scheme = comps.scheme?.lowercased() comps.host = comps.host?.lowercased() var path = comps.path if path.count > 1, path.hasSuffix("/") { path.removeLast() } comps.path = path return comps.url?.absoluteString ?? url.absoluteString } }