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