feat: East Africa + Southern Africa pills with regional sub-sections
- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections - Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized - StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch - sectionSupplement background fetch populates sparse regional sections in All digest - SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5) - Theme: removed standalone Uganda pill, wired new sub-section layouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,23 +17,90 @@ final class StoryStore: ObservableObject {
|
||||
@Published var hasMore = false
|
||||
@Published var selectedTopic: String? = nil
|
||||
@Published var selectedTags: Set<String> = []
|
||||
@Published var sectionSupplement: [String: [StorySummary]] = [:]
|
||||
|
||||
private var nextCursor: String?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var foregroundSyncTask: Task<Void, Never>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Quick cache (UserDefaults — synchronous, no async gap)
|
||||
|
||||
private let quickCacheKey = "jarvis.quickStories.v1"
|
||||
private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days
|
||||
|
||||
/// Restore the last-known feed from UserDefaults synchronously on init.
|
||||
/// This runs before the first SwiftUI render so the feed is never blank.
|
||||
private func restoreQuickCache() {
|
||||
guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return }
|
||||
// Discard if too stale — matches CacheMaintenance window.
|
||||
guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else {
|
||||
UserDefaults.standard.removeObject(forKey: quickCacheKey)
|
||||
return
|
||||
}
|
||||
if !payload.stories.isEmpty { stories = payload.stories }
|
||||
}
|
||||
|
||||
/// Persist the current feed to UserDefaults so it survives process kills.
|
||||
private func persistQuickCache() {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date())
|
||||
if let data = try? encoder.encode(payload) {
|
||||
UserDefaults.standard.set(data, forKey: quickCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuickCachePayload: Codable {
|
||||
let stories: [StorySummary]
|
||||
let savedAt: Date
|
||||
}
|
||||
|
||||
// MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before
|
||||
// the quick cache exists, e.g. app reinstall).
|
||||
func bootstrap(container: ModelContainer) {
|
||||
guard stories.isEmpty else { return }
|
||||
let ctx = ModelContext(container)
|
||||
if let cached = try? ctx.fetch(FetchDescriptor<CachedStory>()) {
|
||||
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||
if !sorted.isEmpty { stories = sorted }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Foreground sync
|
||||
|
||||
/// Call when app becomes active. Polls every 5 min so the feed never runs dry.
|
||||
/// The WebSocket handles real-time events between polls.
|
||||
private var isFetchingFull = false
|
||||
|
||||
/// Load page 1 then paginate to ≤100 stories.
|
||||
/// Guarded against concurrent invocations and rate-limited to ≥30 s between
|
||||
/// fetches so rapid foreground/background cycles don't hammer the API.
|
||||
func loadFeedFull() async {
|
||||
guard !isFetchingFull else { return }
|
||||
// Rate-limit: if the last *successful* sync was < 30 s ago, skip.
|
||||
if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return }
|
||||
isFetchingFull = true
|
||||
defer { isFetchingFull = false }
|
||||
|
||||
await loadStories(refresh: true)
|
||||
var pages = 0
|
||||
while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 {
|
||||
await loadMore()
|
||||
pages += 1
|
||||
}
|
||||
loadSectionSupplements()
|
||||
}
|
||||
|
||||
/// Call when app becomes active. Polls every 90 s so new stories surface fast.
|
||||
func startForegroundSync() {
|
||||
foregroundSyncTask?.cancel()
|
||||
foregroundSyncTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.loadStories(refresh: true)
|
||||
await self?.loadFeedFull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +113,7 @@ final class StoryStore: ObservableObject {
|
||||
private let api = APIClient.shared
|
||||
|
||||
private init() {
|
||||
restoreQuickCache() // synchronous — stories are ready before first render
|
||||
subscribeToWebSocket()
|
||||
}
|
||||
|
||||
@@ -67,20 +135,28 @@ final class StoryStore: ObservableObject {
|
||||
tags: selectedTags
|
||||
)
|
||||
if refresh {
|
||||
// Replace list; count only genuinely new IDs for reshuffle logic.
|
||||
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
|
||||
// Append only IDs not already present — cursor can overlap.
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||
stories += novel
|
||||
newCount = novel.count
|
||||
}
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
lastSyncedAt = Date()
|
||||
persistQuickCache() // survives the next process kill
|
||||
preCacheArticlesInBackground()
|
||||
} catch let e as APIError where !e.isCancelled {
|
||||
error = e
|
||||
} catch {}
|
||||
print("[StoryStore] fetch failed: \(e.localizedDescription)")
|
||||
} catch {
|
||||
print("[StoryStore] unexpected fetch error: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
return newCount
|
||||
@@ -105,35 +181,70 @@ final class StoryStore: ObservableObject {
|
||||
isLoadingMore = true
|
||||
do {
|
||||
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
|
||||
stories += result.data
|
||||
// Deduplicate: cursor-based pages can overlap when stories are added mid-fetch.
|
||||
let existingIds = Set(stories.map(\.id))
|
||||
let novel = result.data.filter { !existingIds.contains($0.id) }
|
||||
stories += novel
|
||||
nextCursor = result.nextCursor
|
||||
hasMore = result.hasMore
|
||||
} catch {}
|
||||
} catch {
|
||||
print("[StoryStore] loadMore error: \(error)")
|
||||
}
|
||||
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.
|
||||
/// Coalesce bursts of `story.created` WS events — waits 3 s then does a
|
||||
/// full paginated load so stories on page 2+ are captured.
|
||||
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)
|
||||
await self?.loadFeedFull()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section supplements
|
||||
// Sections with regional or niche tags rarely rank in the global top-100,
|
||||
// so we fetch a small targeted pool for each sparse section and let
|
||||
// makeDigest() use it as a fallback.
|
||||
|
||||
private let supplementSections: [(id: String, tags: Set<String>)] = [
|
||||
("east-africa", ["east-africa"]),
|
||||
("africa", ["africa"]),
|
||||
("sport", ["sports", "esports", "formula-1"]),
|
||||
("science", ["science", "astronomy"]),
|
||||
("health", ["health", "health-and-wellness"]),
|
||||
]
|
||||
|
||||
func loadSectionSupplements() {
|
||||
guard selectedTags.isEmpty else { return } // only for "All" pill
|
||||
for sec in supplementSections {
|
||||
let id = sec.id
|
||||
let tags = sec.tags
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if let result = try? await api.fetchStories(tags: tags, limit: 8) {
|
||||
await MainActor.run { self.sectionSupplement[id] = result.data }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setTopic(_ topic: String?) async {
|
||||
selectedTopic = topic
|
||||
selectedTags = []
|
||||
await loadStories(refresh: true)
|
||||
await loadFeedFull()
|
||||
}
|
||||
|
||||
func setPill(_ pill: StoryPill) async {
|
||||
selectedTopic = nil
|
||||
selectedTags = pill.slugs
|
||||
nextCursor = nil
|
||||
await loadStories(refresh: true)
|
||||
// Filter changed — must fetch regardless of rate-limit, otherwise
|
||||
// the old stories stay in place and the new pill finds almost nothing.
|
||||
lastSyncedAt = nil
|
||||
await loadFeedFull()
|
||||
}
|
||||
|
||||
// MARK: - WebSocket
|
||||
|
||||
Reference in New Issue
Block a user