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 `"|"` 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])) } }