d161cff806
Boost/reaction/vote state lived only in per-row @State, so leaving the timeline and coming back briefly lost the "already done" indicator until the own-feed scan completed (and that scan hit a stale CDN copy and could miss just-written entries, leaving the indicator wrong for up to a minute). OwnInteractionCache is a thin UserDefaults-backed record of (postURL -> myFeedTimestamp) for each interaction kind. Mutators run from the view models on successful upload/delete. Readers are synchronous and hit only UserDefaults, so loadExisting* resolves instantly on row reappear. The feed-scan path is kept as a fallback so interactions performed from other clients are still detected — and when one is found, it is seeded into the cache so future reappears skip the fetch. Rationale captured from research: - org-social.el doesn't guard against this (it permits double-boosting) so it offers no precedent. - The relay's /interactions/?post=URL already exposes actor feed URLs so a future pass could cross-check against the relay; for now the cache plus feed-scan fallback is enough to fix the regression.
96 lines
4.1 KiB
Swift
96 lines
4.1 KiB
Swift
import Foundation
|
|
|
|
/// Persistent record of interactions (boost, reaction, poll vote) the user
|
|
/// performed through this app. Used as an optimistic overlay so the UI can
|
|
/// render the "already boosted / already reacted / already voted" state
|
|
/// instantly, without waiting for the relay to re-crawl the feed (which takes
|
|
/// up to a minute) or having to refetch the user's own feed on every row
|
|
/// appear (which hit the CDN cache and could miss just-written entries).
|
|
///
|
|
/// Lifecycle:
|
|
/// - Mutators are called on success from the interaction view models after
|
|
/// the upload completes.
|
|
/// - Readers hit only `UserDefaults`, so they are synchronous and safe to
|
|
/// call from the main thread during `task {}` setup.
|
|
/// - Entries persist across app launches until the user explicitly undoes
|
|
/// the action (`removeBoost` / `removeReaction` / `removeVote`).
|
|
@MainActor
|
|
final class OwnInteractionCache {
|
|
|
|
static let shared = OwnInteractionCache()
|
|
|
|
private let defaults = UserDefaults.standard
|
|
private let boostsKey = "own.interactions.boosts.v1"
|
|
private let reactionsKey = "own.interactions.reactions.v1"
|
|
private let votesKey = "own.interactions.votes.v1"
|
|
|
|
// MARK: - Boosts
|
|
|
|
/// Records that the user has boosted `postURL`. `timestamp` is the RFC 3339
|
|
/// ID of the boost post in the user's own feed, kept so an unboost can
|
|
/// locate and delete that post without re-scanning.
|
|
func recordBoost(postURL: String, timestamp: String) {
|
|
var map = defaults.dictionary(forKey: boostsKey) as? [String: String] ?? [:]
|
|
map[postURL] = timestamp
|
|
defaults.set(map, forKey: boostsKey)
|
|
}
|
|
|
|
func removeBoost(postURL: String) {
|
|
var map = defaults.dictionary(forKey: boostsKey) as? [String: String] ?? [:]
|
|
map.removeValue(forKey: postURL)
|
|
defaults.set(map, forKey: boostsKey)
|
|
}
|
|
|
|
/// Returns the boost-post timestamp if the user has boosted `postURL`, else nil.
|
|
func boostTimestamp(for postURL: String) -> String? {
|
|
let map = defaults.dictionary(forKey: boostsKey) as? [String: String] ?? [:]
|
|
return map[postURL]
|
|
}
|
|
|
|
// MARK: - Reactions
|
|
|
|
/// Entries are encoded as `"<emoji>|<timestamp>"` so we can round-trip both
|
|
/// the emoji the user picked and the RFC 3339 ID of the reaction post.
|
|
func recordReaction(postURL: String, emoji: String, timestamp: String) {
|
|
var map = defaults.dictionary(forKey: reactionsKey) as? [String: String] ?? [:]
|
|
map[postURL] = "\(emoji)|\(timestamp)"
|
|
defaults.set(map, forKey: reactionsKey)
|
|
}
|
|
|
|
func removeReaction(postURL: String) {
|
|
var map = defaults.dictionary(forKey: reactionsKey) as? [String: String] ?? [:]
|
|
map.removeValue(forKey: postURL)
|
|
defaults.set(map, forKey: reactionsKey)
|
|
}
|
|
|
|
func reaction(for postURL: String) -> (emoji: String, timestamp: String)? {
|
|
let map = defaults.dictionary(forKey: reactionsKey) as? [String: String] ?? [:]
|
|
guard let raw = map[postURL] else { return nil }
|
|
let parts = raw.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard parts.count == 2 else { return nil }
|
|
return (String(parts[0]), String(parts[1]))
|
|
}
|
|
|
|
// MARK: - Poll votes
|
|
|
|
func recordVote(pollURL: String, option: String, timestamp: String) {
|
|
var map = defaults.dictionary(forKey: votesKey) as? [String: String] ?? [:]
|
|
map[pollURL] = "\(option)|\(timestamp)"
|
|
defaults.set(map, forKey: votesKey)
|
|
}
|
|
|
|
func removeVote(pollURL: String) {
|
|
var map = defaults.dictionary(forKey: votesKey) as? [String: String] ?? [:]
|
|
map.removeValue(forKey: pollURL)
|
|
defaults.set(map, forKey: votesKey)
|
|
}
|
|
|
|
func vote(for pollURL: String) -> (option: String, timestamp: String)? {
|
|
let map = defaults.dictionary(forKey: votesKey) as? [String: String] ?? [:]
|
|
guard let raw = map[pollURL] else { return nil }
|
|
let parts = raw.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard parts.count == 2 else { return nil }
|
|
return (String(parts[0]), String(parts[1]))
|
|
}
|
|
}
|