Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed, story detail, article reader, feed manager, and LAN⇄Tailscale connectivity. Project generated from project.yml via XcodeGen. Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates, backlog, and API/backend handoff docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
143
Jarvis/Store/StoryStore.swift
Normal file
143
Jarvis/Store/StoryStore.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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?
|
||||
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
|
||||
|
||||
if refresh {
|
||||
nextCursor = nil
|
||||
stories = []
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.fetchStories(
|
||||
cursor: nextCursor,
|
||||
topic: selectedTopic
|
||||
)
|
||||
stories = refresh ? result.data : stories + result.data
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
lastSyncedAt = Date()
|
||||
} catch let e as APIError {
|
||||
error = e
|
||||
} catch {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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:
|
||||
Task { await loadStories(refresh: true) }
|
||||
|
||||
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
|
||||
stories.sort { $0.signalScore > $1.signalScore }
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user