import Foundation import Observation import OrgSocialKit @Observable @MainActor final class PollVoteViewModel { var isPosting = false var errorMessage: String? /// The option the user voted for on the target poll, or nil if not voted. var votedOption: String? /// RFC 3339 timestamp of the vote post in the user's own feed, kept so we /// can locate and delete it on unvote. var votedTimestamp: String? /// Restores the voted state for `pollURL`. Reads `OwnInteractionCache` /// first for an instant, relay-independent answer; falls back to scanning /// the user's own feed so votes cast from another client still register. func loadExistingVote(for pollURL: String) async { if let cached = OwnInteractionCache.shared.vote(for: pollURL) { votedOption = cached.option votedTimestamp = 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 == pollURL && post.pollOption?.isEmpty == false && post.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } if let match, let option = match.pollOption { votedOption = option votedTimestamp = match.timestamp OwnInteractionCache.shared.recordVote( pollURL: pollURL, option: option, timestamp: match.timestamp ) } } catch { // Silent: UI will just show the "open to vote" state. } } func vote(for option: String, on pollURL: String) async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before voting." return } isPosting = true errorMessage = nil defer { isPosting = false } do { let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true) let options = NewPostOptions.vote(for: option, on: pollURL) 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) votedOption = option votedTimestamp = ts OwnInteractionCache.shared.recordVote(pollURL: pollURL, option: option, timestamp: ts) } catch { errorMessage = error.localizedDescription } } /// Deletes the user's vote post on `pollURL`. If `votedTimestamp` is unset /// the own feed is scanned to locate the vote. func unvote(from pollURL: String) async { guard let feedURL = publicFeedURL, let uploader = makeUploader() else { errorMessage = "Configure your feed in Settings before removing the vote." 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 = votedTimestamp { timestamp = known } else { let profile = OrgSocialParser().parse(content) guard let match = profile.posts.first(where: { post in post.replyTo == pollURL && post.pollOption?.isEmpty == false && post.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { errorMessage = "Vote 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 { votedOption = nil votedTimestamp = nil OwnInteractionCache.shared.removeVote(pollURL: pollURL) return } try await uploader.upload(content: updated) FollowCoordinator.shared.updateCachedContent(updated) votedOption = nil votedTimestamp = nil OwnInteractionCache.shared.removeVote(pollURL: pollURL) } 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() } }