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 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-18 23:22:56 +03:00
parent 4e4d109380
commit 1e582f5120
9 changed files with 137 additions and 23 deletions

View File

@@ -51,6 +51,6 @@ struct JarvisApp: App {
await notifications.bootstrap() await notifications.bootstrap()
} }
} }
.modelContainer(for: [CachedStory.self, CachedArticle.self]) .modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self])
} }
} }

View File

@@ -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 @Model
final class CachedArticle { final class CachedArticle {
@Attribute(.unique) var id: String @Attribute(.unique) var id: String

View File

@@ -19,6 +19,13 @@ enum APIError: Error, LocalizedError {
case .networkError(let e): return e.localizedDescription 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 { private struct APIErrorResponse: Decodable {

View File

@@ -18,6 +18,7 @@ final class StoryStore: ObservableObject {
@Published var selectedTopic: String? = nil @Published var selectedTopic: String? = nil
private var nextCursor: String? private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let ws = WebSocketManager.shared private let ws = WebSocketManager.shared
private let api = APIClient.shared private let api = APIClient.shared
@@ -33,23 +34,25 @@ final class StoryStore: ObservableObject {
isLoading = true isLoading = true
error = nil error = nil
if refresh { // Don't wipe the visible feed up-front. Only the cursor resets for a
nextCursor = nil // refresh; the list is replaced on success. A cancelled/failed refresh
stories = [] // then keeps showing what we already have instead of blanking out.
} let cursor = refresh ? nil : nextCursor
do { do {
let result = try await api.fetchStories( let result = try await api.fetchStories(
cursor: nextCursor, cursor: cursor,
topic: selectedTopic topic: selectedTopic
) )
stories = refresh ? result.data : stories + result.data stories = refresh ? result.data : stories + result.data
nextCursor = result.nextCursor nextCursor = result.nextCursor
hasMore = result.hasMore hasMore = result.hasMore
lastSyncedAt = Date() lastSyncedAt = Date()
} catch let e as APIError { } catch let e as APIError where !e.isCancelled {
error = e error = e
} catch {} } catch {
// Benign cancellation (network blip / superseded request): keep state.
}
isLoading = false isLoading = false
} }
@@ -66,6 +69,17 @@ final class StoryStore: ObservableObject {
isLoadingMore = false 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 { func setTopic(_ topic: String?) async {
selectedTopic = topic selectedTopic = topic
await loadStories(refresh: true) await loadStories(refresh: true)
@@ -93,7 +107,7 @@ final class StoryStore: ObservableObject {
Task { await refetch(storyId: id) } Task { await refetch(storyId: id) }
case .storyCreated: case .storyCreated:
Task { await loadStories(refresh: true) } scheduleCoalescedRefresh()
case .storyStale: case .storyStale:
guard let id = event.storyId else { return } guard let id = event.storyId else { return }

View File

@@ -18,15 +18,18 @@ struct JarvisWordmark: View {
struct SignalFeedView: View { struct SignalFeedView: View {
@EnvironmentObject var store: StoryStore @EnvironmentObject var store: StoryStore
@EnvironmentObject var ws: WebSocketManager @EnvironmentObject var ws: WebSocketManager
@EnvironmentObject var settings: ServerSettings
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query private var cachedStories: [CachedStory] @Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@State private var showFeeds = false @State private var showFeeds = false
@State private var showSettings = false @State private var showSettings = false
/// Stories that have full article content cached eligible for the green dot. /// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) } private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
/// Live stories when online; cached fallback when the feed is empty & offline. /// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] { private var displayStories: [StorySummary] {
@@ -175,7 +178,9 @@ struct SignalFeedView: View {
} else { } else {
ForEach(displayStories) { story in ForEach(displayStories) { story in
NavigationLink(value: story) { 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) .buttonStyle(.plain)
.onAppear { .onAppear {
@@ -201,20 +206,66 @@ struct SignalFeedView: View {
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
if store.isLoading { if store.isLoading {
// Actively polling the server.
ProgressView().tint(Palette.orange) 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 { } else {
Image(systemName: ws.connectionState.isLive ? "antenna.radiowaves.left.and.right" : "wifi.slash") // Connected, loaded, genuinely nothing yet.
.font(.system(size: 30)) emptyIcon("antenna.radiowaves.left.and.right")
.foregroundStyle(Color(hex: "333333")) Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
Text(ws.connectionState.isLive ? "No signals yet" : "Offline — no cached stories") Text("Pull to refresh")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.foregroundStyle(Color(hex: "555555")) refreshButton("Refresh")
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, 32)
.padding(.top, 80) .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 // MARK: - Caching
private func cacheStories(_ stories: [StorySummary]) { private func cacheStories(_ stories: [StorySummary]) {

View File

@@ -7,12 +7,14 @@ struct StoryRowView: View {
let story: StorySummary let story: StorySummary
/// True when the story's articles are cached in SwiftData (offline-ready). /// True when the story's articles are cached in SwiftData (offline-ready).
var isCached: Bool = false 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) } private var sourceNames: [String] { story.sources.map(\.name) }
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore) SignalStripe(score: story.signalScore, muted: isRead)
VStack(alignment: .leading, spacing: 9) { VStack(alignment: .leading, spacing: 9) {
// Title (+ cached dot) // Title (+ cached dot)
@@ -51,6 +53,7 @@ struct StoryRowView: View {
.padding(.trailing, 16) .padding(.trailing, 16)
.padding(.top, 16) .padding(.top, 16)
} }
.opacity(isRead ? 0.55 : 1)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.black) .background(Color.black)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {

View File

@@ -34,12 +34,15 @@ private struct TabHeader: View {
private struct StoryList: View { private struct StoryList: View {
let stories: [StorySummary] let stories: [StorySummary]
let cachedIds: Set<String> let cachedIds: Set<String>
var readIds: Set<String> = []
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(stories) { story in ForEach(stories) { story in
NavigationLink(value: story) { 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) .buttonStyle(.plain)
} }
@@ -53,6 +56,7 @@ private struct StoryList: View {
struct LatestView: View { struct LatestView: View {
@EnvironmentObject var store: StoryStore @EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
private var latest: [StorySummary] { private var latest: [StorySummary] {
store.stories.sorted { $0.createdAt > $1.createdAt } store.stories.sorted { $0.createdAt > $1.createdAt }
@@ -65,7 +69,8 @@ struct LatestView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
TabHeader(title: "Latest") TabHeader(title: "Latest")
Divider().overlay(Palette.hairline) 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) .navigationBarHidden(true)
@@ -80,6 +85,7 @@ struct LatestView: View {
struct SavedView: View { struct SavedView: View {
@Query private var cachedStories: [CachedStory] @Query private var cachedStories: [CachedStory]
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
private var stories: [StorySummary] { private var stories: [StorySummary] {
cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore } cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore }
@@ -102,7 +108,8 @@ struct SavedView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } 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 { struct SearchView: View {
@EnvironmentObject var store: StoryStore @EnvironmentObject var store: StoryStore
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@State private var query = "" @State private var query = ""
private var results: [StorySummary] { private var results: [StorySummary] {
@@ -141,7 +149,8 @@ struct SearchView: View {
} else if results.isEmpty { } else if results.isEmpty {
placeholder("No matches for “\(query)") placeholder("No matches for “\(query)")
} else { } else {
StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId))) StoryList(stories: results, cachedIds: Set(cachedArticles.map(\.storyId)),
readIds: Set(readStories.map(\.id)))
} }
} }
} }

View File

@@ -6,10 +6,12 @@ import SwiftUI
struct SignalStripe: View { struct SignalStripe: View {
let score: Int let score: Int
var width: CGFloat = 3 var width: CGFloat = 3
/// Read stories drop the colored stripe for a neutral one.
var muted: Bool = false
var body: some View { var body: some View {
Rectangle() Rectangle()
.fill(Signal.stripeColor(score)) .fill(muted ? Palette.hairline : Signal.stripeColor(score))
.frame(width: width) .frame(width: width)
} }
} }

View File

@@ -30,6 +30,7 @@ struct StoryDetailView: View {
let story: StorySummary let story: StorySummary
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@StateObject private var vm = StoryDetailViewModel() @StateObject private var vm = StoryDetailViewModel()
@Query private var cachedArticles: [CachedArticle] @Query private var cachedArticles: [CachedArticle]
@@ -102,7 +103,23 @@ struct StoryDetailView: View {
.toolbarBackground(Color.black, for: .navigationBar) .toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark) .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<ReadStory>(predicate: #Predicate { $0.id == id })
)
if existing?.first == nil {
modelContext.insert(ReadStory(id: id))
try? modelContext.save()
}
} }
// MARK: - Pieces // MARK: - Pieces