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:
Robin Kutesa
2026-06-21 01:58:51 +03:00
parent 3b09fa2889
commit eec610024c
3 changed files with 60 additions and 4 deletions

View File

@@ -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() {

View File

@@ -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 }
} }
} }

View File

@@ -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) {