Files
org-social-ios/App/ViewModels/OwnInteractionCache.swift
andros d161cff806 Persist own-interaction state in UserDefaults to survive relaunches
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.
2026-04-22 12:28:10 +02:00

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