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:
@@ -65,6 +65,9 @@ enum BackgroundRefreshManager {
|
|||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
// Pre-cache articles for offline reading
|
||||||
|
await preCacheArticles(page.data, container: container)
|
||||||
|
|
||||||
if !newStories.isEmpty {
|
if !newStories.isEmpty {
|
||||||
notifyNewStories(newStories)
|
notifyNewStories(newStories)
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,30 @@ enum BackgroundRefreshManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Article pre-caching
|
||||||
|
|
||||||
|
/// Fetch and store the top article for each high-signal uncached story so
|
||||||
|
/// the user can read them offline without having opened them first.
|
||||||
|
/// Safe to call in background or foreground — creates its own ModelContext.
|
||||||
|
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let top = stories
|
||||||
|
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
|
||||||
|
.sorted { $0.signalScore > $1.signalScore }
|
||||||
|
.prefix(20)
|
||||||
|
|
||||||
|
for story in top {
|
||||||
|
let sid = story.id
|
||||||
|
let alreadyCached = (try? context.fetch(
|
||||||
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
|
||||||
|
))?.isEmpty == false
|
||||||
|
guard !alreadyCached, let src = story.sources.first else { continue }
|
||||||
|
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
|
||||||
|
context.insert(CachedArticle(from: art))
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - New-story notifications
|
// MARK: - New-story notifications
|
||||||
|
|
||||||
// Slugs for the featured categories the user cares about.
|
// Slugs for the featured categories the user cares about.
|
||||||
|
|||||||
@@ -51,15 +51,14 @@ final class StoryStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Load
|
// MARK: - Load
|
||||||
|
|
||||||
func loadStories(refresh: Bool = false) async {
|
@discardableResult
|
||||||
guard !isLoading else { return }
|
func loadStories(refresh: Bool = false) async -> Int {
|
||||||
|
guard !isLoading else { return 0 }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
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
|
let cursor = refresh ? nil : nextCursor
|
||||||
|
var newCount = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await api.fetchStories(
|
let result = try await api.fetchStories(
|
||||||
@@ -67,17 +66,38 @@ final class StoryStore: ObservableObject {
|
|||||||
topic: selectedTopic,
|
topic: selectedTopic,
|
||||||
tags: selectedTags
|
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
|
nextCursor = result.nextCursor
|
||||||
hasMore = result.hasMore
|
hasMore = result.hasMore
|
||||||
lastSyncedAt = Date()
|
lastSyncedAt = Date()
|
||||||
|
preCacheArticlesInBackground()
|
||||||
} catch let e as APIError where !e.isCancelled {
|
} 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
|
||||||
|
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 {
|
func loadMore() async {
|
||||||
|
|||||||
@@ -251,11 +251,20 @@ struct SignalFeedView: View {
|
|||||||
.animation(.snappy, value: readStories.count)
|
.animation(.snappy, value: readStories.count)
|
||||||
.onAppear { captureSeenSnapshot() }
|
.onAppear { captureSeenSnapshot() }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
// Reshuffle: clear the seen snapshot so all stories re-rank by signal
|
|
||||||
// score, and any stories cached by background refresh float to the top.
|
|
||||||
seenSnapshot = []
|
seenSnapshot = []
|
||||||
seenTracker.ids = []
|
seenTracker.ids = []
|
||||||
await store.loadStories(refresh: true)
|
let new = await store.loadStories(refresh: true)
|
||||||
|
// Nothing new from server — pull in any stories cached by background
|
||||||
|
// refresh that aren't already in the live feed, then reshuffle.
|
||||||
|
if new == 0 {
|
||||||
|
let liveIds = Set(store.stories.map(\.id))
|
||||||
|
let extra = cachedStories
|
||||||
|
.filter { !liveIds.contains($0.id) }
|
||||||
|
.map(StorySummary.init(cached:))
|
||||||
|
if !extra.isEmpty {
|
||||||
|
await store.mergeStories(extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user