import Foundation import Observation import OrgSocialKit @Observable @MainActor final class BoostViewModel { var isPosting = false var boosted = false var errorMessage: String? /// RFC 3339 timestamp of the boost post in the user's own feed. Kept so /// we can locate and delete it on unboost without another full scan. var boostedTimestamp: String? /// Restores the boosted state for `postURL`. Reads `OwnInteractionCache` /// first (instant, persistent, covers just-written boosts that the relay /// and CDN haven't propagated yet). Falls back to scanning the user's own /// feed so boosts performed by the user on another client are still /// reflected. func loadExistingBoost(for postURL: String) async { if let timestamp = OwnInteractionCache.shared.boostTimestamp(for: postURL) { boosted = true boostedTimestamp = timestamp return } guard let feedURL = publicFeedURL else { return } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: false) let profile = OrgSocialParser().parse(content) if let match = profile.posts.first(where: { $0.include == postURL }) { boosted = true boostedTimestamp = match.timestamp // Cache so future loads skip the feed fetch. OwnInteractionCache.shared.recordBoost(postURL: postURL, timestamp: match.timestamp) } } catch { // Silent: the button will just default to the "not boosted" state. } } func boost(postURL: String, commentary: String = "") async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before boosting." return } isPosting = true errorMessage = nil defer { isPosting = false } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true) let options = NewPostOptions.boost(of: postURL, commentary: commentary) let writer = PostWriter() let date = Date() let (updated, _) = try writer.appendPost(to: content, feedURL: feedURL, options: options, date: date) try await uploader.upload(content: updated) FollowCoordinator.shared.updateCachedContent(updated) let ts = writer.generateTimestamp(for: date) boosted = true boostedTimestamp = ts OwnInteractionCache.shared.recordBoost(postURL: postURL, timestamp: ts) } catch { errorMessage = error.localizedDescription } } /// Deletes the user's boost of `postURL`. If `boostedTimestamp` is unset, /// the own feed is scanned to locate the boost post first. func unboost(postURL: String) async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before removing the boost." return } isPosting = true errorMessage = nil defer { isPosting = false } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true) let timestamp: String if let known = boostedTimestamp { timestamp = known } else { let profile = OrgSocialParser().parse(content) guard let match = profile.posts.first(where: { $0.include == postURL }) else { errorMessage = "Boost post not found in your feed." return } timestamp = match.timestamp } let writer = PostWriter() let updated: String do { updated = try writer.deletePost(timestamp: timestamp, from: content) } catch PostWriterError.postNotFound { // Cache was stale (the boost was already removed, perhaps // from another client). Self-heal: clear the cache and flip // the UI to "not boosted" without surfacing an error. boosted = false boostedTimestamp = nil OwnInteractionCache.shared.removeBoost(postURL: postURL) return } try await uploader.upload(content: updated) FollowCoordinator.shared.updateCachedContent(updated) boosted = false boostedTimestamp = nil OwnInteractionCache.shared.removeBoost(postURL: postURL) } catch { errorMessage = error.localizedDescription } } private var publicFeedURL: URL? { guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"), let url = URL(string: raw), url.scheme?.hasPrefix("http") == true else { return nil } return url } private func makeUploader() -> (any FeedUploader)? { UploaderFactory.makeUploader() } }