feat: read-shelf auto-expand, stripe removal, pull-reshuffle, BG notifications
- Mark-read now immediately expands the READ shelf so the story is visible - Orange signal stripe is fully removed (not dimmed) when isRead=true - Pull-to-refresh clears seen snapshot → full reshuffle by signal score - BackgroundRefreshManager fires a local notification for the top new high-signal story after each cache refresh; Tech/AI/HomeLab stories get their category in the notification title (≥65 signal threshold) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
||||||
|
|
||||||
@@ -47,25 +48,71 @@ enum BackgroundRefreshManager {
|
|||||||
let page = try await APIClient.shared.fetchStories(limit: 40)
|
let page = try await APIClient.shared.fetchStories(limit: 40)
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
|
var newStories: [StorySummary] = []
|
||||||
|
|
||||||
for story in page.data {
|
for story in page.data {
|
||||||
let id = story.id
|
let id = story.id
|
||||||
if let existing = (try? context.fetch(
|
if let existing = (try? context.fetch(
|
||||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||||
// Refresh signal score and freshness on already-cached rows.
|
|
||||||
existing.signalScore = story.signalScore
|
existing.signalScore = story.signalScore
|
||||||
existing.sourceCount = story.sourceCount
|
existing.sourceCount = story.sourceCount
|
||||||
existing.updatedAt = story.updatedAt
|
existing.updatedAt = story.updatedAt
|
||||||
} else {
|
} else {
|
||||||
context.insert(CachedStory(from: story))
|
context.insert(CachedStory(from: story))
|
||||||
|
newStories.append(story)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
if !newStories.isEmpty {
|
||||||
|
notifyNewStories(newStories)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Network unavailable in background — fail silently, next run will catch up.
|
// Network unavailable in background — fail silently, next run will catch up.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - New-story notifications
|
||||||
|
|
||||||
|
// Slugs for the featured categories the user cares about.
|
||||||
|
private static let featuredSlugs: Set<String> = [
|
||||||
|
"artificial-intelligence", "machine-learning", "robotics",
|
||||||
|
"technology", "cybersecurity", "security", "privacy-and-data-protection",
|
||||||
|
"programming-and-software-development", "cloud-computing", "web-design-and-ui-ux",
|
||||||
|
"homelab",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let minSignal = 65
|
||||||
|
|
||||||
|
private static func notifyNewStories(_ stories: [StorySummary]) {
|
||||||
|
// Best overall story above threshold.
|
||||||
|
let eligible = stories
|
||||||
|
.filter { $0.signalScore >= minSignal }
|
||||||
|
.sorted { $0.signalScore > $1.signalScore }
|
||||||
|
guard let lead = eligible.first else { return }
|
||||||
|
|
||||||
|
let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? []))
|
||||||
|
let isFeatured = !tags.isDisjoint(with: featuredSlugs)
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = isFeatured
|
||||||
|
? "📡 \(Topic.label(lead.topic))"
|
||||||
|
: "📡 New signals"
|
||||||
|
content.body = lead.headline
|
||||||
|
if eligible.count > 1 {
|
||||||
|
content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")"
|
||||||
|
}
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let req = UNNotificationRequest(
|
||||||
|
identifier: "feed.new.\(UUID().uuidString)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
UNUserNotificationCenter.current().add(req)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Schedule
|
// MARK: - Schedule
|
||||||
|
|
||||||
static func scheduleFeedRefresh() {
|
static func scheduleFeedRefresh() {
|
||||||
|
|||||||
@@ -272,9 +272,13 @@ struct SignalFeedView: View {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.animation(.snappy, value: showRead)
|
.animation(.snappy, value: showRead)
|
||||||
|
.animation(.snappy, value: readStories.count)
|
||||||
.onAppear { captureSeenSnapshot() }
|
.onAppear { captureSeenSnapshot() }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
captureSeenSnapshot() // sink what you've already seen
|
// 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 = []
|
||||||
|
seenTracker.ids = []
|
||||||
await store.loadStories(refresh: true)
|
await store.loadStories(refresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,6 +570,7 @@ struct SignalFeedView: View {
|
|||||||
if existing?.first == nil {
|
if existing?.first == nil {
|
||||||
modelContext.insert(ReadStory(id: id))
|
modelContext.insert(ReadStory(id: id))
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
withAnimation(.snappy) { showRead = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ struct StoryRowView: View {
|
|||||||
|
|
||||||
private var fullBody: some View {
|
private var fullBody: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
SignalStripe(score: story.signalScore, muted: isRead)
|
if isRead {
|
||||||
|
// No stripe for read stories — remove the orange signal indicator entirely.
|
||||||
|
Color.clear.frame(width: 3)
|
||||||
|
} else {
|
||||||
|
SignalStripe(score: story.signalScore)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 9) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
// Title (+ cached dot)
|
// Title (+ cached dot)
|
||||||
@@ -90,7 +95,6 @@ struct StoryRowView: View {
|
|||||||
.padding(.trailing, 16)
|
.padding(.trailing, 16)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
}
|
}
|
||||||
.opacity(isRead ? 0.55 : 1)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
|
|||||||
Reference in New Issue
Block a user