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,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 }
|
||||
|
||||
Reference in New Issue
Block a user