diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift index 4b3b69e..14d75f1 100644 --- a/Jarvis/Networking/APIClient.swift +++ b/Jarvis/Networking/APIClient.swift @@ -33,23 +33,37 @@ private struct APIErrorResponse: Decodable { let error: Inner } +private struct EmptyResponse: Decodable {} + actor APIClient { static let shared = APIClient() private var baseURL: URL? private let session: URLSession private let decoder: JSONDecoder + private let deviceId: String private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 10 self.session = URLSession(configuration: config) + self.deviceId = APIClient.loadDeviceId() self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .iso8601 self.decoder.keyDecodingStrategy = .convertFromSnakeCase } + private static func loadDeviceId() -> String { + let key = "jarvis.device.id" + if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty { + return existing + } + let value = UUID().uuidString.lowercased() + UserDefaults.standard.set(value, forKey: key) + return value + } + func configure(host: String) throws { guard let url = URL(string: "http://\(host)/api/v1") else { throw APIError.invalidURL @@ -70,9 +84,10 @@ actor APIClient { topic: String? = nil, tags: Set = [], minSignal: Int? = nil, - limit: Int = 20 + limit: Int = 20, + includeRead: Bool = false ) async throws -> PaginatedStories { - var params: [String: String] = ["limit": "\(limit)"] + var params: [String: String] = ["limit": "\(limit)", "include_read": includeRead ? "true" : "false"] if let cursor { params["after"] = cursor } if let topic { params["topic"] = topic } if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") } @@ -84,6 +99,18 @@ actor APIClient { try await get("/stories/\(id)") } + func markStoryRead(id: String) async throws { + try await postVoid("/stories/\(id)/read", body: [ + "device_id": deviceId, + "read_at": ISO8601DateFormatter().string(from: Date()), + "source": "ios", + ]) + } + + func markStoryUnread(id: String) async throws { + try await delete("/stories/\(id)/read") + } + // MARK: - Articles func fetchArticle(id: String) async throws -> Article { @@ -118,6 +145,7 @@ actor APIClient { guard let url = components.url else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" + request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id") return try await execute(request) } @@ -126,16 +154,32 @@ actor APIClient { let url = base.appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "POST" + request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) return try await execute(request) } + private func postVoid(_ path: String, body: [String: Any]) async throws { + guard let base = baseURL else { throw APIError.notConnected } + let url = base.appendingPathComponent(path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw APIError.serverError(code: "post_failed", message: "Post failed", status: 500) + } + } + private func delete(_ path: String) async throws { guard let base = baseURL else { throw APIError.notConnected } let url = base.appendingPathComponent(path) var request = URLRequest(url: url) request.httpMethod = "DELETE" + request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id") let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 204 else { throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500) diff --git a/Jarvis/Store/CacheMaintenance.swift b/Jarvis/Store/CacheMaintenance.swift index 74d4fc2..fd9031a 100644 --- a/Jarvis/Store/CacheMaintenance.swift +++ b/Jarvis/Store/CacheMaintenance.swift @@ -10,8 +10,8 @@ enum CacheMaintenance { private static let hour: TimeInterval = 3_600 private static let day: TimeInterval = 86_400 - private static let recentHours = 84.0 // 3.5 days: survives weekends - private static let markerHours = 96.0 // 4 days: active window + buffer + private static let recentHours = 72.0 // 3 days: mirrors backend active story window + private static let markerHours = 192.0 // 8 days: read suppression survives weekly repeats private static let articleDays = 14.0 // cached article bodies (backend article retention) /// Age-based eviction. Run on launch. Cheap; protects saved stories. diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index ff5f635..77084f6 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -114,6 +114,7 @@ struct SignalFeedView: View { FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) try? modelContext.save() + Task { try? await APIClient.shared.markStoryUnread(id: id) } } } @@ -632,6 +633,7 @@ struct SignalFeedView: View { if existing?.first == nil { modelContext.insert(ReadStory(id: id)) try? modelContext.save() + Task { try? await APIClient.shared.markStoryRead(id: id) } withAnimation(.snappy) { showRead = true } } } diff --git a/Jarvis/Views/Reader/ArticleReaderView.swift b/Jarvis/Views/Reader/ArticleReaderView.swift index b8583bf..b2e4b42 100644 --- a/Jarvis/Views/Reader/ArticleReaderView.swift +++ b/Jarvis/Views/Reader/ArticleReaderView.swift @@ -431,8 +431,10 @@ struct ArticleReaderView: View { if let row = (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) + Task { try? await APIClient.shared.markStoryUnread(id: id) } } else { modelContext.insert(ReadStory(id: id)) + Task { try? await APIClient.shared.markStoryRead(id: id) } } try? modelContext.save() } diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift index 6cac6b4..055b314 100644 --- a/Jarvis/Views/Story/StoryDetailView.swift +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -142,6 +142,7 @@ struct StoryDetailView: View { else { return } modelContext.insert(ReadStory(id: id)) try? modelContext.save() + Task { try? await APIClient.shared.markStoryRead(id: id) } } private func toggleRead() { @@ -149,8 +150,10 @@ struct StoryDetailView: View { if let row = (try? modelContext.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { modelContext.delete(row) + Task { try? await APIClient.shared.markStoryUnread(id: id) } } else { modelContext.insert(ReadStory(id: id)) + Task { try? await APIClient.shared.markStoryRead(id: id) } } try? modelContext.save() }