a1cc454c7a
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.
133 lines
5.3 KiB
Swift
133 lines
5.3 KiB
Swift
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 }
|
|
}
|