2026-06-18 18:04:59 +03:00
|
|
|
// StoryStore.swift
|
|
|
|
|
// Jarvis — central observable store. Views read from here, never hit the API directly.
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
import Combine
|
|
|
|
|
import SwiftData
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
final class StoryStore: ObservableObject {
|
|
|
|
|
static let shared = StoryStore()
|
|
|
|
|
|
|
|
|
|
@Published var stories: [StorySummary] = []
|
|
|
|
|
@Published var isLoading = false
|
|
|
|
|
@Published var isLoadingMore = false
|
|
|
|
|
@Published var error: APIError?
|
|
|
|
|
@Published var lastSyncedAt: Date?
|
|
|
|
|
@Published var hasMore = false
|
|
|
|
|
@Published var selectedTopic: String? = nil
|
|
|
|
|
|
|
|
|
|
private var nextCursor: String?
|
2026-06-18 23:22:56 +03:00
|
|
|
private var refreshTask: Task<Void, Never>?
|
2026-06-18 18:04:59 +03:00
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
private let ws = WebSocketManager.shared
|
|
|
|
|
private let api = APIClient.shared
|
|
|
|
|
|
|
|
|
|
private init() {
|
|
|
|
|
subscribeToWebSocket()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Load
|
|
|
|
|
|
|
|
|
|
func loadStories(refresh: Bool = false) async {
|
|
|
|
|
guard !isLoading else { return }
|
|
|
|
|
isLoading = true
|
|
|
|
|
error = nil
|
|
|
|
|
|
2026-06-18 23:22:56 +03:00
|
|
|
// Don't wipe the visible feed up-front. Only the cursor resets for a
|
|
|
|
|
// refresh; the list is replaced on success. A cancelled/failed refresh
|
|
|
|
|
// then keeps showing what we already have instead of blanking out.
|
|
|
|
|
let cursor = refresh ? nil : nextCursor
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let result = try await api.fetchStories(
|
2026-06-18 23:22:56 +03:00
|
|
|
cursor: cursor,
|
2026-06-18 18:04:59 +03:00
|
|
|
topic: selectedTopic
|
|
|
|
|
)
|
|
|
|
|
stories = refresh ? result.data : stories + result.data
|
|
|
|
|
nextCursor = result.nextCursor
|
|
|
|
|
hasMore = result.hasMore
|
|
|
|
|
lastSyncedAt = Date()
|
2026-06-18 23:22:56 +03:00
|
|
|
} catch let e as APIError where !e.isCancelled {
|
2026-06-18 18:04:59 +03:00
|
|
|
error = e
|
2026-06-18 23:22:56 +03:00
|
|
|
} catch {
|
|
|
|
|
// Benign cancellation (network blip / superseded request): keep state.
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
isLoading = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadMore() async {
|
|
|
|
|
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
|
|
|
|
isLoadingMore = true
|
|
|
|
|
do {
|
|
|
|
|
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic)
|
|
|
|
|
stories += result.data
|
|
|
|
|
nextCursor = result.nextCursor
|
|
|
|
|
hasMore = result.hasMore
|
|
|
|
|
} catch {}
|
|
|
|
|
isLoadingMore = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 23:22:56 +03:00
|
|
|
/// Coalesce bursts of `story.created` events into a single refresh so a busy
|
|
|
|
|
/// feed (47 sources) doesn't fire a refetch per story.
|
|
|
|
|
private func scheduleCoalescedRefresh() {
|
|
|
|
|
refreshTask?.cancel()
|
|
|
|
|
refreshTask = Task { [weak self] in
|
|
|
|
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
|
|
|
guard !Task.isCancelled else { return }
|
|
|
|
|
await self?.loadStories(refresh: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:04:59 +03:00
|
|
|
func setTopic(_ topic: String?) async {
|
|
|
|
|
selectedTopic = topic
|
|
|
|
|
await loadStories(refresh: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - WebSocket
|
|
|
|
|
|
|
|
|
|
private func subscribeToWebSocket() {
|
|
|
|
|
ws.events
|
|
|
|
|
.receive(on: DispatchQueue.main)
|
|
|
|
|
.sink { [weak self] event in
|
|
|
|
|
self?.handle(event: event)
|
|
|
|
|
}
|
|
|
|
|
.store(in: &cancellables)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func handle(event: WSEvent) {
|
|
|
|
|
switch event.type {
|
|
|
|
|
|
|
|
|
|
case .storyUpdated:
|
|
|
|
|
guard let id = event.storyId,
|
|
|
|
|
let score = event.signalScore,
|
|
|
|
|
let count = event.sourceCount else { return }
|
|
|
|
|
updateStory(id: id, signalScore: score, sourceCount: count)
|
|
|
|
|
Task { await refetch(storyId: id) }
|
|
|
|
|
|
|
|
|
|
case .storyCreated:
|
2026-06-18 23:22:56 +03:00
|
|
|
scheduleCoalescedRefresh()
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
case .storyStale:
|
|
|
|
|
guard let id = event.storyId else { return }
|
|
|
|
|
removeStory(id: id)
|
|
|
|
|
|
|
|
|
|
case .feedHealth:
|
|
|
|
|
NotificationCenter.default.post(name: .feedHealthChanged, object: event)
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateStory(id: String, signalScore: Int, sourceCount: Int) {
|
|
|
|
|
guard let idx = stories.firstIndex(where: { $0.id == id }) else { return }
|
|
|
|
|
let old = stories[idx]
|
|
|
|
|
let updated = StorySummary(
|
|
|
|
|
id: old.id,
|
|
|
|
|
headline: old.headline,
|
|
|
|
|
summary: old.summary,
|
|
|
|
|
topic: old.topic,
|
|
|
|
|
signalScore: signalScore,
|
|
|
|
|
scoreBreakdown: old.scoreBreakdown,
|
|
|
|
|
sourceCount: sourceCount,
|
|
|
|
|
sources: old.sources,
|
|
|
|
|
consensus: old.consensus,
|
|
|
|
|
conflict: old.conflict,
|
|
|
|
|
updatedAt: old.updatedAt,
|
|
|
|
|
createdAt: old.createdAt
|
|
|
|
|
)
|
|
|
|
|
stories[idx] = updated
|
2026-06-18 23:33:22 +03:00
|
|
|
stories.sort(by: StorySummary.feedOrder)
|
2026-06-18 18:04:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func removeStory(id: String) {
|
|
|
|
|
stories.removeAll { $0.id == id }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func refetch(storyId: String) async {
|
|
|
|
|
// Lightweight: only update what changed via WS patch above.
|
|
|
|
|
// Full detail is fetched lazily when user taps into the story.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension Notification.Name {
|
|
|
|
|
static let feedHealthChanged = Notification.Name("feedHealthChanged")
|
|
|
|
|
}
|