feat: offline article caching + smart pull-to-refresh reshuffle
Offline caching: - BackgroundRefreshManager.preCacheArticles() fetches top article per story (score ≥60, up to 20 stories) and stores as CachedArticle in SwiftData - Runs after every BG fetch AND after every foreground sync via StoryStore - Articles are available in ArticleReaderView offline without ever opening them Pull-to-refresh reshuffle when nothing new: - loadStories(refresh:) now returns newCount (genuinely new story IDs) - If server returns 0 new stories, feed merges in any BG-cached stories not already in the live feed, then re-sorts by signal score (reshuffle) - seenSnapshot is always cleared so everything re-ranks fresh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,15 +51,14 @@ final class StoryStore: ObservableObject {
|
||||
|
||||
// MARK: - Load
|
||||
|
||||
func loadStories(refresh: Bool = false) async {
|
||||
guard !isLoading else { return }
|
||||
@discardableResult
|
||||
func loadStories(refresh: Bool = false) async -> Int {
|
||||
guard !isLoading else { return 0 }
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
// 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
|
||||
var newCount = 0
|
||||
|
||||
do {
|
||||
let result = try await api.fetchStories(
|
||||
@@ -67,17 +66,38 @@ final class StoryStore: ObservableObject {
|
||||
topic: selectedTopic,
|
||||
tags: selectedTags
|
||||
)
|
||||
stories = refresh ? result.data : stories + result.data
|
||||
if refresh {
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
newCount = result.data.filter { !existingIds.contains($0.id) }.count
|
||||
stories = result.data
|
||||
} else {
|
||||
stories += result.data
|
||||
newCount = result.data.count
|
||||
}
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
lastSyncedAt = Date()
|
||||
preCacheArticlesInBackground()
|
||||
} catch let e as APIError where !e.isCancelled {
|
||||
error = e
|
||||
} catch {
|
||||
// Benign cancellation (network blip / superseded request): keep state.
|
||||
}
|
||||
} catch {}
|
||||
|
||||
isLoading = false
|
||||
return newCount
|
||||
}
|
||||
|
||||
private func preCacheArticlesInBackground() {
|
||||
guard let container = BackgroundRefreshManager.container else { return }
|
||||
let snapshot = stories
|
||||
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
|
||||
}
|
||||
|
||||
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
|
||||
func mergeStories(_ extra: [StorySummary]) async {
|
||||
let existing = Set(stories.map(\.id))
|
||||
let novel = extra.filter { !existing.contains($0.id) }
|
||||
guard !novel.isEmpty else { return }
|
||||
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
|
||||
}
|
||||
|
||||
func loadMore() async {
|
||||
|
||||
Reference in New Issue
Block a user