Files
andros a1cc454c7a Self-heal stale OwnInteractionCache entries
If the cache says "you've boosted/reacted/voted on X" but the post it
pointed to has since disappeared from the user's feed (e.g. removed from
another client, or a leftover entry from a test session whose upload was
later rolled back), the UI stayed locked in the "Remove boost" state.
Tapping the button fired unboost, which in turn called
PostWriter.deletePost with the stale timestamp, got .postNotFound, caught
the error into errorMessage (never shown), and left the state unchanged —
a hard-to-diagnose dead state.

Two reconciliations, one local, one via the relay:

- unboost / unreact / unvote now treat .postNotFound as "cache was
  stale": wipe the cache entry, flip the view model to the not-done
  state, return without surfacing an error. The next tap will correctly
  be a fresh boost/react/vote.

- fetchInteractionData cross-checks bidirectionally. Previously the relay
  could only flip boosted=true (if the viewer showed up in its boost
  list); it could never flip it to false. It now also clears a stale
  cache entry when the relay confirms the viewer is NOT in the boost
  list, but only after a 120s grace window measured from the cached
  boost timestamp — otherwise a just-written boost would get wiped
  before the relay's once-per-minute scan had a chance to index it.
2026-04-22 13:43:04 +02:00

129 lines
5.1 KiB
Swift

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() }
}