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:
@@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user