// // FeedStore.swift // iris // // Created by Codex. // import Foundation final class FeedStore { struct CardKey: Hashable, Codable { let id: String let type: FeedItemType } struct CardState: Codable, Equatable { var lastShownAt: Int? var dismissedUntil: Int? var snoozedUntil: Int? } private struct Persisted: Codable { var feed: FeedEnvelope? var states: [String: CardState] } private let queue = DispatchQueue(label: "iris.feedstore.queue") private let fileURL: URL private var cachedFeed: FeedEnvelope? private var states: [String: CardState] = [:] init(filename: String = "feed_store_v1.json") { self.fileURL = Self.defaultFileURL(filename: filename) let persisted = Self.load(from: fileURL) self.cachedFeed = persisted?.feed self.states = persisted?.states ?? [:] } func getFeed(now: Int = Int(Date().timeIntervalSince1970)) -> FeedEnvelope { queue.sync { guard let feed = cachedFeed else { return FeedEnvelope.allQuiet(now: now, reason: "no_feed", source: "store") } let filtered = normalizedFeed(feed, now: now) if filtered.feed.isEmpty { return FeedEnvelope.allQuiet(now: now, reason: "expired_or_suppressed", source: "store") } return filtered } } func setFeed(_ feed: FeedEnvelope, now: Int = Int(Date().timeIntervalSince1970)) { queue.sync { let normalized = normalizedFeed(feed, now: now, applyingSuppression: false) cachedFeed = normalized markShown(feed: normalized, now: now) save() } } func lastShownAt(feedItemId: String) -> Int? { queue.sync { let matches = states.compactMap { (key, value) -> Int? in guard key.hasSuffix("|" + feedItemId) else { return nil } return value.lastShownAt } return matches.max() } } func isSuppressed(id: String, type: FeedItemType, now: Int) -> Bool { queue.sync { let key = Self.keyString(id: id, type: type) guard let state = states[key] else { return false } if let until = state.dismissedUntil, until > now { return true } if let until = state.snoozedUntil, until > now { return true } return false } } func dismiss(id: String, type: FeedItemType, until: Int? = nil) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() state.dismissedUntil = until ?? Int.max states[key] = state save() } } func snooze(id: String, type: FeedItemType, until: Int) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() state.snoozedUntil = until states[key] = state save() } } func clearSuppression(id: String, type: FeedItemType) { queue.sync { let key = Self.keyString(id: id, type: type) var state = states[key] ?? CardState() state.dismissedUntil = nil state.snoozedUntil = nil states[key] = state save() } } private func markShown(feed: FeedEnvelope, now: Int) { for card in feed.feed { let key = Self.keyString(id: card.id, type: card.type) var state = states[key] ?? CardState() state.lastShownAt = now states[key] = state } } private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope { let normalizedCards = feed.feed.compactMap { card -> FeedItem? in let ttl = max(1, card.ttlSec) if feed.generatedAt + ttl <= now { return nil } if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil } return FeedItem( id: card.id, type: card.type, title: card.title.truncated(maxLength: TextConstraints.titleMax), subtitle: card.subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: min(max(card.priority, 0.0), 1.0), ttlSec: ttl, condition: card.condition, startsAt: card.startsAt, bucket: card.bucket, actions: card.actions ) } let winnerId = normalizedCards.first(where: { $0.id == feed.meta.winnerId })?.id ?? normalizedCards.first?.id ?? "quiet-000" let normalized = FeedEnvelope( schema: 1, generatedAt: max(1, feed.generatedAt), feed: normalizedCards, meta: FeedMeta(winnerId: winnerId, unreadCount: normalizedCards.count) ) return normalized } private func isSuppressedUnlocked(id: String, type: FeedItemType, now: Int) -> Bool { let key = Self.keyString(id: id, type: type) guard let state = states[key] else { return false } if let until = state.dismissedUntil, until > now { return true } if let until = state.snoozedUntil, until > now { return true } return false } private func save() { let persisted = Persisted(feed: cachedFeed, states: states) Self.save(persisted, to: fileURL) } private static func keyString(id: String, type: FeedItemType) -> String { "\(type.rawValue)|\(id)" } private static func defaultFileURL(filename: String) -> URL { let fm = FileManager.default let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory let bundle = Bundle.main.bundleIdentifier ?? "iris" return base .appendingPathComponent(bundle, isDirectory: true) .appendingPathComponent(filename, isDirectory: false) } private static func load(from url: URL) -> Persisted? { guard let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode(Persisted.self, from: data) } private static func save(_ persisted: Persisted, to url: URL) { do { let fm = FileManager.default try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.outputFormatting = [.withoutEscapingSlashes] let data = try encoder.encode(persisted) try data.write(to: url, options: [.atomic]) } catch { // Best-effort persistence. } } }