import Foundation import Observation import OrgSocialKit @Observable @MainActor final class ReactionViewModel { var isPosting = false var errorMessage: String? var postedEmoji: String? /// RFC 3339 ID of the reaction post in the user's own feed. Kept so we /// can locate and delete it when the user removes the reaction. var postedTimestamp: String? /// Restores the reacted state for `postURL`. Reads `OwnInteractionCache` /// first for an instant, relay-independent answer; falls back to scanning /// the user's own feed for reactions added from another client. func loadExistingReaction(for postURL: String) async { if let cached = OwnInteractionCache.shared.reaction(for: postURL) { postedEmoji = cached.emoji postedTimestamp = cached.timestamp return } guard let feedURL = publicFeedURL else { return } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: false) let profile = OrgSocialParser().parse(content) let match = profile.posts.first { post in post.replyTo == postURL && post.mood?.isEmpty == false && post.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } if let match, let emoji = match.mood { postedEmoji = emoji postedTimestamp = match.timestamp OwnInteractionCache.shared.recordReaction( postURL: postURL, emoji: emoji, timestamp: match.timestamp ) } } catch { // Silent: just means the reacted-state chip won't show. } } func react(emoji: String, to postURL: String) async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before reacting." return } isPosting = true errorMessage = nil defer { isPosting = false } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true) let options = NewPostOptions.reaction(to: postURL, mood: emoji) 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) postedEmoji = emoji postedTimestamp = ts OwnInteractionCache.shared.recordReaction(postURL: postURL, emoji: emoji, timestamp: ts) } catch { errorMessage = error.localizedDescription } } /// Deletes the user's reaction post on `postURL`. If `postedTimestamp` is /// unset (e.g. because it was left over from a previous session without /// scanning), the own feed is scanned first to locate the post. func unreact(from postURL: String) async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before removing the reaction." 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 = postedTimestamp { timestamp = known } else { let profile = OrgSocialParser().parse(content) guard let match = profile.posts.first(where: { post in post.replyTo == postURL && post.mood?.isEmpty == false && post.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { errorMessage = "Reaction 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 { postedEmoji = nil postedTimestamp = nil OwnInteractionCache.shared.removeReaction(postURL: postURL) return } try await uploader.upload(content: updated) FollowCoordinator.shared.updateCachedContent(updated) postedEmoji = nil postedTimestamp = nil OwnInteractionCache.shared.removeReaction(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() } } private extension String? { var nilIfEmpty: String? { self?.isEmpty == false ? self : nil } }