feat: sync read/unread state to backend with persistent device ID
- APIClient: generate and persist a stable device UUID (jarvis.device.id in UserDefaults); attach as X-Jarvis-Device-Id header on every request - Add markStoryRead / markStoryUnread — POST/DELETE /stories/:id/read with device_id, read_at, and source fields - Wire both calls into all three read-toggle sites: SignalFeedView, ArticleReaderView, StoryDetailView - Add postVoid helper for fire-and-forget POST calls (no response body needed) - Pass include_read param on stories fetch for future server-side filtering - CacheMaintenance: recentHours 84→72 (mirrors backend 72h story window); markerHours 96→192 so read suppression survives a full weekly news cycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,23 +33,37 @@ private struct APIErrorResponse: Decodable {
|
|||||||
let error: Inner
|
let error: Inner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct EmptyResponse: Decodable {}
|
||||||
|
|
||||||
actor APIClient {
|
actor APIClient {
|
||||||
static let shared = APIClient()
|
static let shared = APIClient()
|
||||||
|
|
||||||
private var baseURL: URL?
|
private var baseURL: URL?
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
|
private let deviceId: String
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 10
|
config.timeoutIntervalForRequest = 10
|
||||||
self.session = URLSession(configuration: config)
|
self.session = URLSession(configuration: config)
|
||||||
|
self.deviceId = APIClient.loadDeviceId()
|
||||||
|
|
||||||
self.decoder = JSONDecoder()
|
self.decoder = JSONDecoder()
|
||||||
self.decoder.dateDecodingStrategy = .iso8601
|
self.decoder.dateDecodingStrategy = .iso8601
|
||||||
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
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 {
|
func configure(host: String) throws {
|
||||||
guard let url = URL(string: "http://\(host)/api/v1") else {
|
guard let url = URL(string: "http://\(host)/api/v1") else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
@@ -70,9 +84,10 @@ actor APIClient {
|
|||||||
topic: String? = nil,
|
topic: String? = nil,
|
||||||
tags: Set<String> = [],
|
tags: Set<String> = [],
|
||||||
minSignal: Int? = nil,
|
minSignal: Int? = nil,
|
||||||
limit: Int = 20
|
limit: Int = 20,
|
||||||
|
includeRead: Bool = false
|
||||||
) async throws -> PaginatedStories {
|
) 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 cursor { params["after"] = cursor }
|
||||||
if let topic { params["topic"] = topic }
|
if let topic { params["topic"] = topic }
|
||||||
if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
|
if !tags.isEmpty { params["tags"] = tags.sorted().joined(separator: ",") }
|
||||||
@@ -84,6 +99,18 @@ actor APIClient {
|
|||||||
try await get("/stories/\(id)")
|
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
|
// MARK: - Articles
|
||||||
|
|
||||||
func fetchArticle(id: String) async throws -> Article {
|
func fetchArticle(id: String) async throws -> Article {
|
||||||
@@ -118,6 +145,7 @@ actor APIClient {
|
|||||||
guard let url = components.url else { throw APIError.invalidURL }
|
guard let url = components.url else { throw APIError.invalidURL }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
return try await execute(request)
|
return try await execute(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,16 +154,32 @@ actor APIClient {
|
|||||||
let url = base.appendingPathComponent(path)
|
let url = base.appendingPathComponent(path)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
return try await execute(request)
|
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 {
|
private func delete(_ path: String) async throws {
|
||||||
guard let base = baseURL else { throw APIError.notConnected }
|
guard let base = baseURL else { throw APIError.notConnected }
|
||||||
let url = base.appendingPathComponent(path)
|
let url = base.appendingPathComponent(path)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
|
request.setValue(deviceId, forHTTPHeaderField: "X-Jarvis-Device-Id")
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
|
guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
|
||||||
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
|
throw APIError.serverError(code: "delete_failed", message: "Delete failed", status: 500)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ enum CacheMaintenance {
|
|||||||
private static let hour: TimeInterval = 3_600
|
private static let hour: TimeInterval = 3_600
|
||||||
private static let day: TimeInterval = 86_400
|
private static let day: TimeInterval = 86_400
|
||||||
|
|
||||||
private static let recentHours = 84.0 // 3.5 days: survives weekends
|
private static let recentHours = 72.0 // 3 days: mirrors backend active story window
|
||||||
private static let markerHours = 96.0 // 4 days: active window + buffer
|
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)
|
private static let articleDays = 14.0 // cached article bodies (backend article retention)
|
||||||
|
|
||||||
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ struct SignalFeedView: View {
|
|||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +633,7 @@ struct SignalFeedView: View {
|
|||||||
if existing?.first == nil {
|
if existing?.first == nil {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
withAnimation(.snappy) { showRead = true }
|
withAnimation(.snappy) { showRead = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,8 +431,10 @@ struct ArticleReaderView: View {
|
|||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ struct StoryDetailView: View {
|
|||||||
else { return }
|
else { return }
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleRead() {
|
private func toggleRead() {
|
||||||
@@ -149,8 +150,10 @@ struct StoryDetailView: View {
|
|||||||
if let row = (try? modelContext.fetch(
|
if let row = (try? modelContext.fetch(
|
||||||
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
modelContext.delete(row)
|
modelContext.delete(row)
|
||||||
|
Task { try? await APIClient.shared.markStoryUnread(id: id) }
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
|
Task { try? await APIClient.shared.markStoryRead(id: id) }
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user