From 1e582f51207a7f69a644f8c4a29b77e93dc14811 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Thu, 18 Jun 2026 23:22:56 +0300 Subject: [PATCH] Feed resilience, clearer empty states, and read/unread - Don't blank the feed on refresh: keep current stories until new data arrives, and ignore benign URLError.cancelled (-999) so a network blip no longer wipes the list. Coalesce WebSocket story.created bursts into a single debounced refresh. - Empty state now distinguishes polling, couldn't-reach-server (with the error + host + Retry), offline, and genuinely-empty. - Track read stories in SwiftData (ReadStory); mark a story read when opened and drop/dim its signal stripe across the feed and Latest/Saved/Search. Co-Authored-By: Claude Opus 4.8 --- Jarvis/JarvisApp.swift | 2 +- Jarvis/Models/Models.swift | 11 ++++ Jarvis/Networking/APIClient.swift | 7 +++ Jarvis/Store/StoryStore.swift | 30 ++++++++--- Jarvis/Views/Home/SignalFeedView.swift | 65 +++++++++++++++++++++--- Jarvis/Views/Home/StoryRowView.swift | 5 +- Jarvis/Views/SecondaryTabs.swift | 17 +++++-- Jarvis/Views/Shared/SignalStripe.swift | 4 +- Jarvis/Views/Story/StoryDetailView.swift | 19 ++++++- 9 files changed, 137 insertions(+), 23 deletions(-) diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 7865532..0564695 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -51,6 +51,6 @@ struct JarvisApp: App { await notifications.bootstrap() } } - .modelContainer(for: [CachedStory.self, CachedArticle.self]) + .modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self]) } } diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index a5d9ce4..5839022 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -188,6 +188,17 @@ final class CachedStory { } } +@Model +final class ReadStory { + @Attribute(.unique) var id: String + var readAt: Date + + init(id: String) { + self.id = id + self.readAt = Date() + } +} + @Model final class CachedArticle { @Attribute(.unique) var id: String diff --git a/Jarvis/Networking/APIClient.swift b/Jarvis/Networking/APIClient.swift index ef566e5..8824a61 100644 --- a/Jarvis/Networking/APIClient.swift +++ b/Jarvis/Networking/APIClient.swift @@ -19,6 +19,13 @@ enum APIError: Error, LocalizedError { case .networkError(let e): return e.localizedDescription } } + + /// A benign URLSession cancellation (-999), e.g. a request superseded or + /// dropped on a network-path change. Should not be shown or wipe state. + var isCancelled: Bool { + if case .networkError(let e) = self { return (e as? URLError)?.code == .cancelled } + return false + } } private struct APIErrorResponse: Decodable { diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 21f5c58..62069e4 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -18,6 +18,7 @@ final class StoryStore: ObservableObject { @Published var selectedTopic: String? = nil private var nextCursor: String? + private var refreshTask: Task? private var cancellables = Set() private let ws = WebSocketManager.shared private let api = APIClient.shared @@ -33,23 +34,25 @@ final class StoryStore: ObservableObject { isLoading = true error = nil - if refresh { - nextCursor = nil - stories = [] - } + // 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 do { let result = try await api.fetchStories( - cursor: nextCursor, + cursor: cursor, topic: selectedTopic ) stories = refresh ? result.data : stories + result.data nextCursor = result.nextCursor hasMore = result.hasMore lastSyncedAt = Date() - } catch let e as APIError { + } catch let e as APIError where !e.isCancelled { error = e - } catch {} + } catch { + // Benign cancellation (network blip / superseded request): keep state. + } isLoading = false } @@ -66,6 +69,17 @@ final class StoryStore: ObservableObject { isLoadingMore = false } + /// 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) + } + } + func setTopic(_ topic: String?) async { selectedTopic = topic await loadStories(refresh: true) @@ -93,7 +107,7 @@ final class StoryStore: ObservableObject { Task { await refetch(storyId: id) } case .storyCreated: - Task { await loadStories(refresh: true) } + scheduleCoalescedRefresh() case .storyStale: guard let id = event.storyId else { return } diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index eed6a6d..368fddf 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -18,15 +18,18 @@ struct JarvisWordmark: View { struct SignalFeedView: View { @EnvironmentObject var store: StoryStore @EnvironmentObject var ws: WebSocketManager + @EnvironmentObject var settings: ServerSettings @Environment(\.modelContext) private var modelContext @Query private var cachedStories: [CachedStory] @Query private var cachedArticles: [CachedArticle] + @Query private var readStories: [ReadStory] @State private var showFeeds = false @State private var showSettings = false /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } + private var readStoryIds: Set { Set(readStories.map(\.id)) } /// Live stories when online; cached fallback when the feed is empty & offline. private var displayStories: [StorySummary] { @@ -175,7 +178,9 @@ struct SignalFeedView: View { } else { ForEach(displayStories) { story in NavigationLink(value: story) { - StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id)) + StoryRowView(story: story, + isCached: cachedStoryIds.contains(story.id), + isRead: readStoryIds.contains(story.id)) } .buttonStyle(.plain) .onAppear { @@ -201,20 +206,66 @@ struct SignalFeedView: View { private var emptyState: some View { VStack(spacing: 12) { if store.isLoading { + // Actively polling the server. ProgressView().tint(Palette.orange) + Text("Polling signals…") + .font(.system(size: 14, weight: .heavy)) + .foregroundStyle(Color(hex: "AAAAAA")) + hostLine + } else if let error = store.error { + // Reached the empty state because the fetch failed. + emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555")) + Text("Couldn't reach the server") + .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + Text(error.errorDescription ?? "Unknown error") + .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) + .multilineTextAlignment(.center) + hostLine + refreshButton("Retry") + } else if !ws.connectionState.isLive { + emptyIcon("wifi.slash") + Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + Text("No cached stories to show") + .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) + refreshButton("Retry") } else { - Image(systemName: ws.connectionState.isLive ? "antenna.radiowaves.left.and.right" : "wifi.slash") - .font(.system(size: 30)) - .foregroundStyle(Color(hex: "333333")) - Text(ws.connectionState.isLive ? "No signals yet" : "Offline — no cached stories") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(Color(hex: "555555")) + // Connected, loaded, genuinely nothing yet. + emptyIcon("antenna.radiowaves.left.and.right") + Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + Text("Pull to refresh") + .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) + refreshButton("Refresh") } } .frame(maxWidth: .infinity) + .padding(.horizontal, 32) .padding(.top, 80) } + private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View { + Image(systemName: name).font(.system(size: 30)).foregroundStyle(color) + } + + @ViewBuilder private var hostLine: some View { + if let host = settings.host { + Text(host) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color(hex: "555555")) + } + } + + private func refreshButton(_ title: String) -> some View { + Button { + Task { await store.loadStories(refresh: true) } + } label: { + Text(title) + .font(.system(size: 14, weight: .bold)).foregroundStyle(.black) + .padding(.horizontal, 22).frame(height: 44) + .background(Palette.orange).clipShape(Capsule()) + } + .padding(.top, 6) + } + // MARK: - Caching private func cacheStories(_ stories: [StorySummary]) { diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 8f73c0d..564b4b5 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -7,12 +7,14 @@ struct StoryRowView: View { let story: StorySummary /// True when the story's articles are cached in SwiftData (offline-ready). var isCached: Bool = false + /// True once the story has been opened — drops the orange stripe and dims it. + var isRead: Bool = false private var sourceNames: [String] { story.sources.map(\.name) } var body: some View { HStack(alignment: .top, spacing: 0) { - SignalStripe(score: story.signalScore) + SignalStripe(score: story.signalScore, muted: isRead) VStack(alignment: .leading, spacing: 9) { // Title (+ cached dot) @@ -51,6 +53,7 @@ struct StoryRowView: View { .padding(.trailing, 16) .padding(.top, 16) } + .opacity(isRead ? 0.55 : 1) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black) .overlay(alignment: .bottom) { diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift index b624166..62da8d3 100644 --- a/Jarvis/Views/SecondaryTabs.swift +++ b/Jarvis/Views/SecondaryTabs.swift @@ -34,12 +34,15 @@ private struct TabHeader: View { private struct StoryList: View { let stories: [StorySummary] let cachedIds: Set + var readIds: Set = [] var body: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(stories) { story in NavigationLink(value: story) { - StoryRowView(story: story, isCached: cachedIds.contains(story.id)) + StoryRowView(story: story, + isCached: cachedIds.contains(story.id), + isRead: readIds.contains(story.id)) } .buttonStyle(.plain) } @@ -53,6 +56,7 @@ private struct StoryList: View { struct LatestView: View { @EnvironmentObject var store: StoryStore @Query private var cachedArticles: [CachedArticle] + @Query private var readStories: [ReadStory] private var latest: [StorySummary] { store.stories.sorted { $0.createdAt > $1.createdAt } @@ -65,7 +69,8 @@ struct LatestView: View { VStack(spacing: 0) { TabHeader(title: "Latest") Divider().overlay(Palette.hairline) - StoryList(stories: latest, cachedIds: Set(cachedArticles.map(\.storyId))) + StoryList(stories: latest, cachedIds: Set(cachedArticles.map(\.storyId)), + readIds: Set(readStories.map(\.id))) } } .navigationBarHidden(true) @@ -80,6 +85,7 @@ struct LatestView: View { struct SavedView: View { @Query private var cachedStories: [CachedStory] @Query private var cachedArticles: [CachedArticle] + @Query private var readStories: [ReadStory] private var stories: [StorySummary] { cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore } @@ -102,7 +108,8 @@ struct SavedView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId))) + StoryList(stories: stories, cachedIds: Set(cachedArticles.map(\.storyId)), + readIds: Set(readStories.map(\.id))) } } } @@ -118,6 +125,7 @@ struct SavedView: View { struct SearchView: View { @EnvironmentObject var store: StoryStore @Query private var cachedArticles: [CachedArticle] + @Query private var readStories: [ReadStory] @State private var query = "" private var results: [StorySummary] { @@ -141,7 +149,8 @@ struct SearchView: View { } else if results.isEmpty { placeholder("No matches for “\(query)”") } else { - StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId))) + StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId)), + readIds: Set(readStories.map(\.id))) } } } diff --git a/Jarvis/Views/Shared/SignalStripe.swift b/Jarvis/Views/Shared/SignalStripe.swift index e41be24..cfe2071 100644 --- a/Jarvis/Views/Shared/SignalStripe.swift +++ b/Jarvis/Views/Shared/SignalStripe.swift @@ -6,10 +6,12 @@ import SwiftUI struct SignalStripe: View { let score: Int var width: CGFloat = 3 + /// Read stories drop the colored stripe for a neutral one. + var muted: Bool = false var body: some View { Rectangle() - .fill(Signal.stripeColor(score)) + .fill(muted ? Palette.hairline : Signal.stripeColor(score)) .frame(width: width) } } diff --git a/Jarvis/Views/Story/StoryDetailView.swift b/Jarvis/Views/Story/StoryDetailView.swift index 968a860..7db2ee2 100644 --- a/Jarvis/Views/Story/StoryDetailView.swift +++ b/Jarvis/Views/Story/StoryDetailView.swift @@ -30,6 +30,7 @@ struct StoryDetailView: View { let story: StorySummary @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext @StateObject private var vm = StoryDetailViewModel() @Query private var cachedArticles: [CachedArticle] @@ -102,7 +103,23 @@ struct StoryDetailView: View { .toolbarBackground(Color.black, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .preferredColorScheme(.dark) - .task { await vm.load(id: story.id) } + .task { + markRead() + await vm.load(id: story.id) + } + } + + // MARK: - Read state + + private func markRead() { + let id = story.id + let existing = try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == id }) + ) + if existing?.first == nil { + modelContext.insert(ReadStory(id: id)) + try? modelContext.save() + } } // MARK: - Pieces