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

@@ -18,6 +18,7 @@ final class StoryStore: ObservableObject {
@Published var selectedTopic: String? = nil
private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
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 }