~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
77 lines · 3.5 KB Raw History
import Foundation

/// Synchronous disk cache for the first-page feed response.
///
/// Allows `FeedViewModel.init()` to pre-populate `galleries` before the first
/// SwiftUI body evaluation, so the feed renders with real content immediately
/// while the background network refresh runs.
///
/// Entries are filed under the account that fetched them — a feed is personal,
/// and switching accounts must not flash the previous one's galleries.
final class FeedCache: @unchecked Sendable {
    static let shared = FeedCache()

    private let directory: URL

    private init() {
        let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
        directory = caches.appendingPathComponent("grain_feed_cache", isDirectory: true)
        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
    }

    /// Load cached galleries for `key`. Returns `[]` if no cache exists or decode fails.
    func load(key: String) -> [GrainGallery] {
        guard let data = try? Data(contentsOf: fileURL(for: key)),
              let galleries = try? JSONDecoder().decode([GrainGallery].self, from: data)
        else { return [] }
        return galleries
    }

    /// Persist `galleries` to disk for `key`, filed under `did`.
    /// No-ops on empty arrays.
    ///
    /// The owning account is a parameter rather than a lookup because saving
    /// happens on a detached task: resolving "who is signed in" here would read
    /// it *after* the fetch, so switching accounts while a feed was in flight
    /// filed one account's feed under the other's cache.
    func save(_ galleries: [GrainGallery], key: String, did: String?) {
        guard !galleries.isEmpty,
              let data = try? JSONEncoder().encode(galleries)
        else { return }
        try? data.write(to: fileURL(for: key, did: did), options: .atomic)
    }

    /// Drop every entry belonging to `did` (that account was signed out).
    func purge(did: String) {
        let suffix = "\(Self.accountSeparator)\(Self.sanitize(did)).json"
        guard let entries = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
        for entry in entries where entry.lastPathComponent.hasSuffix(suffix) {
            try? FileManager.default.removeItem(at: entry)
        }
    }

    /// Re-file entries written before the cache was account-scoped under `did`,
    /// the account that must have written them.
    func adoptLegacyEntries(did: String) {
        guard let entries = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
        for entry in entries where !entry.lastPathComponent.contains(Self.accountSeparator) {
            let key = entry.deletingPathExtension().lastPathComponent
            try? FileManager.default.moveItem(at: entry, to: fileURL(for: key, did: did))
        }
    }

    private static let accountSeparator = "--acct-"

    private static func sanitize(_ value: String) -> String {
        value
            .replacingOccurrences(of: "/", with: "-")
            .replacingOccurrences(of: ":", with: "-")
            .replacingOccurrences(of: " ", with: "_")
    }

    private func fileURL(for key: String, did: String? = AccountScopedStorage.activeAccountID) -> URL {
        let safe = Self.sanitize(key)
        guard let did else { return directory.appendingPathComponent("\(safe).json") }
        return directory.appendingPathComponent("\(safe)\(Self.accountSeparator)\(Self.sanitize(did)).json")
    }
}