The "All" feed stuttered because every row appearing ran markSeen, which did a disk save(), an O(n) contains over a @Query'd SeenStory array, and triggered a full feed re-render on each insert. Now markSeen does an O(1) insert into a non-observed reference Set, no per-row save (the container autosaves), and SeenStory is no longer @Query'd by the view. cacheStories also does one fetch + an in-memory index instead of a query per story. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
523 lines
20 KiB
Swift
523 lines
20 KiB
Swift
// SignalFeedView.swift
|
|
// Jarvis — home screen. Stories ranked by signal score, live connection state,
|
|
// topic filters, pull-to-refresh, infinite scroll.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
// Reusable wordmark: J in KisaniOrange, the rest white, weight 800, kerning -2.
|
|
struct JarvisWordmark: View {
|
|
var size: CGFloat = 30
|
|
var body: some View {
|
|
(Text("J").foregroundColor(Palette.orange) + Text("arvis").foregroundColor(.white))
|
|
.font(.system(size: size, weight: .heavy))
|
|
.kerning(-2)
|
|
}
|
|
}
|
|
|
|
struct SignalFeedView: View {
|
|
@EnvironmentObject var store: StoryStore
|
|
@EnvironmentObject var ws: WebSocketManager
|
|
@EnvironmentObject var settings: ServerSettings
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
|
@Query private var cachedStories: [CachedStory]
|
|
@Query private var cachedArticles: [CachedArticle]
|
|
@Query private var readStories: [ReadStory]
|
|
@Query private var savedStories: [SavedStory]
|
|
@State private var showFeeds = false
|
|
@State private var showSettings = false
|
|
@State private var selectedPill: StoryPill = .all
|
|
@State private var shareStory: StorySummary?
|
|
/// Snapshot of already-seen story ids, captured per load so the order stays
|
|
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
|
@State private var seenSnapshot: Set<String> = []
|
|
/// Reference-type set so marking a story seen on scroll doesn't re-render.
|
|
@State private var seenTracker = SeenTracker()
|
|
@State private var showRead = false
|
|
|
|
/// Stories that have full article content cached → eligible for the green dot.
|
|
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
|
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
|
|
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
|
|
|
|
/// Live stories when online; cached fallback when offline. Filtered by the
|
|
/// selected pill via backend tags — no headline keyword matching.
|
|
private var filteredStories: [StorySummary] {
|
|
let base: [StorySummary]
|
|
if !store.stories.isEmpty {
|
|
base = store.stories.sorted(by: StorySummary.feedOrder)
|
|
} else if !ws.connectionState.isLive {
|
|
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
|
} else {
|
|
base = []
|
|
}
|
|
return base.filter { $0.matches(selectedPill) }
|
|
}
|
|
|
|
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
|
|
private var mainStories: [StorySummary] {
|
|
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
|
|
let fresh = notRead.filter { !seenSnapshot.contains($0.id) }
|
|
let seen = notRead.filter { seenSnapshot.contains($0.id) }
|
|
return fresh + seen
|
|
}
|
|
|
|
/// Read stories, tucked into the collapsible "Read" shelf.
|
|
private var readItems: [StorySummary] {
|
|
filteredStories.filter { readStoryIds.contains($0.id) }
|
|
}
|
|
|
|
private func captureSeenSnapshot() {
|
|
let ids = (try? modelContext.fetch(FetchDescriptor<SeenStory>()))?.map(\.id) ?? []
|
|
seenSnapshot = Set(ids)
|
|
seenTracker.ids = seenSnapshot
|
|
}
|
|
|
|
/// Hot path — runs as each row scrolls in. O(1), no disk write, no re-render:
|
|
/// the container autosaves, and SeenStory isn't @Query'd by this view.
|
|
private func markSeen(_ s: StorySummary) {
|
|
guard seenTracker.ids.insert(s.id).inserted else { return }
|
|
modelContext.insert(SeenStory(id: s.id))
|
|
}
|
|
|
|
private func markUnread(_ s: StorySummary) {
|
|
let id = s.id
|
|
if let row = (try? modelContext.fetch(
|
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id })))?.first {
|
|
modelContext.delete(row)
|
|
try? modelContext.save()
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.black.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
header
|
|
syncBar
|
|
topicPills
|
|
columnHeaders
|
|
Divider().overlay(Palette.hairline)
|
|
feedList
|
|
}
|
|
}
|
|
.navigationBarHidden(true)
|
|
.navigationDestination(for: StorySummary.self) { story in
|
|
StoryDetailView(story: story)
|
|
}
|
|
.navigationDestination(for: ArticleRoute.self) { route in
|
|
ArticleReaderView(route: route)
|
|
}
|
|
.sheet(isPresented: $showFeeds) {
|
|
FeedManagerView()
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView()
|
|
}
|
|
.sheet(item: $shareStory) { story in
|
|
ShareSheet(items: shareItems(story))
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.onChange(of: store.stories) { _, newValue in
|
|
cacheStories(newValue)
|
|
}
|
|
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
|
|
.task(id: selectedPill) {
|
|
// A filter active but nothing matched in the loaded set → pull a few
|
|
// more pages so sparse pills still populate.
|
|
guard selectedPill != .all else { return }
|
|
var pages = 0
|
|
while filteredStories.isEmpty && store.hasMore && pages < 8 {
|
|
await store.loadMore()
|
|
pages += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .center) {
|
|
JarvisWordmark(size: 30)
|
|
Spacer()
|
|
ConnectionBanner(state: ws.connectionState)
|
|
Button {
|
|
showFeeds = true
|
|
} label: {
|
|
Image(systemName: "dot.radiowaves.up.forward")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(Color(hex: "888888"))
|
|
.frame(width: 44, height: 44)
|
|
}
|
|
Button {
|
|
showSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(Color(hex: "888888"))
|
|
.frame(width: 44, height: 44)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// MARK: - Sync bar
|
|
|
|
private var syncBar: some View {
|
|
Text(syncText)
|
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(Color(hex: "5A5A5A"))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
private var syncText: String {
|
|
let ago = syncedMinutesAgo(store.lastSyncedAt)
|
|
let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago"
|
|
if ws.connectionState.isLive {
|
|
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
|
|
} else {
|
|
return "Last synced \(phrase)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Pills
|
|
|
|
private var topicPills: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(StoryPill.allCases) { p in
|
|
pill(p.label, selected: selectedPill == p) { selectedPill = p }
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
.padding(.bottom, 14)
|
|
}
|
|
|
|
private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(label)
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(selected ? .black : Color(hex: "AAAAAA"))
|
|
.padding(.horizontal, 14)
|
|
.frame(height: 32)
|
|
.background(selected ? Palette.orange : Palette.surface)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// MARK: - Column headers
|
|
|
|
private var columnHeaders: some View {
|
|
HStack {
|
|
Text("STORY")
|
|
.kerning(0.8)
|
|
Spacer()
|
|
Text("SIGNAL ↓")
|
|
.kerning(0.8)
|
|
}
|
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(Color(hex: "555555"))
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - Feed list
|
|
|
|
private var feedList: some View {
|
|
List {
|
|
if mainStories.isEmpty && readItems.isEmpty {
|
|
emptyState.plainBlackRow()
|
|
} else {
|
|
ForEach(mainStories) { story in
|
|
mainRow(story)
|
|
}
|
|
if mainStories.isEmpty {
|
|
caughtUpRow.plainBlackRow()
|
|
}
|
|
if store.isLoadingMore {
|
|
ProgressView().tint(Palette.orange)
|
|
.frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow()
|
|
}
|
|
readShelf
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.black)
|
|
.animation(.snappy, value: showRead)
|
|
.onAppear { captureSeenSnapshot() }
|
|
.refreshable {
|
|
captureSeenSnapshot() // sink what you've already seen
|
|
await store.loadStories(refresh: true)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func mainRow(_ story: StorySummary) -> some View {
|
|
NavigationLink(value: story) {
|
|
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id))
|
|
}
|
|
.plainBlackRow()
|
|
.onAppear {
|
|
markSeen(story)
|
|
if story.id == mainStories.last?.id { Task { await store.loadMore() } }
|
|
}
|
|
// Swipe left: short swipe reveals Save (tap); a full/long swipe marks read.
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button { markRead(story) } label: {
|
|
Label("Mark read", systemImage: "checkmark.circle")
|
|
}
|
|
.tint(Color(hex: "3A5A7A"))
|
|
Button { toggleSave(story) } label: {
|
|
Label(isSaved(story) ? "Saved" : "Save",
|
|
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
|
|
}
|
|
.tint(Palette.healthActive)
|
|
}
|
|
// Swipe right → Share.
|
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
|
Button { markRead(story); shareStory = story } label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
.tint(Palette.orange)
|
|
}
|
|
}
|
|
|
|
private var caughtUpRow: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "checkmark.circle")
|
|
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
|
|
Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
|
|
Text("Pull to refresh for more")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 70)
|
|
}
|
|
|
|
// Collapsible "Read" shelf at the bottom — read stories as slim rows.
|
|
@ViewBuilder
|
|
private var readShelf: some View {
|
|
if !readItems.isEmpty {
|
|
Button {
|
|
withAnimation(.snappy) { showRead.toggle() }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 13)).foregroundStyle(Color(hex: "3E7E3E"))
|
|
Text("READ").font(.system(size: 11, weight: .bold, design: .monospaced)).kerning(0.8)
|
|
Text("\(readItems.count)")
|
|
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(Color(hex: "888888"))
|
|
Spacer()
|
|
Image(systemName: showRead ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: 11, weight: .bold))
|
|
}
|
|
.foregroundStyle(Color(hex: "666666"))
|
|
.padding(.horizontal, 16).padding(.vertical, 16)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.plainBlackRow()
|
|
|
|
if showRead {
|
|
ForEach(readItems) { story in
|
|
NavigationLink(value: story) {
|
|
StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id),
|
|
isRead: true, compact: true)
|
|
}
|
|
.plainBlackRow()
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button { markUnread(story) } label: {
|
|
Label("Unread", systemImage: "circle")
|
|
}
|
|
.tint(Color(hex: "3A5A7A"))
|
|
Button { toggleSave(story) } label: {
|
|
Label(isSaved(story) ? "Saved" : "Save",
|
|
systemImage: isSaved(story) ? "bookmark.fill" : "bookmark")
|
|
}
|
|
.tint(Palette.healthActive)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 12) {
|
|
if store.isLoading {
|
|
// Actively polling the server.
|
|
ProgressView().tint(Palette.orange)
|
|
Text("Polling signals…")
|
|
.font(.system(size: 14, weight: .heavy))
|
|
.foregroundStyle(Color(hex: "AAAAAA"))
|
|
hostLine
|
|
} else if let error = store.error {
|
|
// Reached the empty state because the fetch failed.
|
|
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
|
|
Text("Couldn't reach the server")
|
|
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
|
Text(error.errorDescription ?? "Unknown error")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
.multilineTextAlignment(.center)
|
|
hostLine
|
|
refreshButton("Retry")
|
|
} else if !ws.connectionState.isLive {
|
|
emptyIcon("wifi.slash")
|
|
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
|
Text("No cached stories to show")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
refreshButton("Retry")
|
|
} else if selectedPill != .all {
|
|
emptyIcon("line.3.horizontal.decrease.circle")
|
|
Text("No \(selectedPill.label) stories yet")
|
|
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
|
if store.isLoadingMore || store.hasMore {
|
|
Text("Pulling more to find them…")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
ProgressView().tint(Palette.orange)
|
|
} else {
|
|
Text("Nothing in the feed matched")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
refreshButton("Refresh")
|
|
}
|
|
} else {
|
|
// Connected, loaded, genuinely nothing yet.
|
|
emptyIcon("antenna.radiowaves.left.and.right")
|
|
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
|
|
Text("Pull to refresh")
|
|
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
|
refreshButton("Refresh")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 32)
|
|
.padding(.top, 80)
|
|
}
|
|
|
|
private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View {
|
|
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
|
|
}
|
|
|
|
@ViewBuilder private var hostLine: some View {
|
|
if let host = settings.host {
|
|
Text(host)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Color(hex: "555555"))
|
|
}
|
|
}
|
|
|
|
private func refreshButton(_ title: String) -> some View {
|
|
Button {
|
|
Task { await store.loadStories(refresh: true) }
|
|
} label: {
|
|
Text(title)
|
|
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
|
|
.padding(.horizontal, 22).frame(height: 44)
|
|
.background(Palette.orange).clipShape(Capsule())
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
|
|
// MARK: - Swipe actions
|
|
|
|
private func isSaved(_ s: StorySummary) -> Bool { savedStoryIds.contains(s.id) }
|
|
|
|
/// Swipe left → toggle Save (bookmarks the story to the Saved tab).
|
|
private func toggleSave(_ s: StorySummary) {
|
|
let id = s.id
|
|
if let row = (try? modelContext.fetch(
|
|
FetchDescriptor<SavedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
|
modelContext.delete(row)
|
|
} else {
|
|
cacheStories([s]) // keep metadata so it renders in Saved
|
|
modelContext.insert(SavedStory(id: id))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
private func markRead(_ s: StorySummary) {
|
|
let id = s.id
|
|
let existing = try? modelContext.fetch(
|
|
FetchDescriptor<ReadStory>(predicate: #Predicate { $0.id == id }))
|
|
if existing?.first == nil {
|
|
modelContext.insert(ReadStory(id: id))
|
|
try? modelContext.save()
|
|
}
|
|
}
|
|
|
|
private func shareItems(_ s: StorySummary) -> [Any] {
|
|
var items: [Any] = ["\(s.headline)\n\n\(s.summary)\n\nvia Jarvis"]
|
|
if let first = s.sources.first, let url = URL(string: first.url) {
|
|
items.append(url)
|
|
}
|
|
return items
|
|
}
|
|
|
|
// MARK: - Caching
|
|
|
|
private func cacheStories(_ stories: [StorySummary]) {
|
|
guard !stories.isEmpty else { return }
|
|
// One fetch + an in-memory index, instead of a query per story.
|
|
let existing = (try? modelContext.fetch(FetchDescriptor<CachedStory>())) ?? []
|
|
var byId = Dictionary(existing.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
|
for summary in stories {
|
|
if let found = byId[summary.id] {
|
|
found.signalScore = summary.signalScore
|
|
found.sourceCount = summary.sourceCount
|
|
found.tags = summary.tags ?? []
|
|
found.updatedAt = summary.updatedAt
|
|
} else {
|
|
let c = CachedStory(from: summary)
|
|
modelContext.insert(c)
|
|
byId[summary.id] = c
|
|
}
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
}
|
|
|
|
/// Session-seen ids in a reference type — mutating it doesn't trigger SwiftUI
|
|
/// updates, so marking rows seen on scroll stays cheap.
|
|
private final class SeenTracker {
|
|
var ids = Set<String>()
|
|
}
|
|
|
|
// Map a cached story back into a StorySummary for offline rendering.
|
|
extension StorySummary {
|
|
init(cached: CachedStory) {
|
|
self.init(
|
|
id: cached.id,
|
|
headline: cached.headline,
|
|
summary: cached.summary,
|
|
topic: cached.topic,
|
|
tags: cached.tags,
|
|
signalScore: cached.signalScore,
|
|
scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0,
|
|
crossSourceConfirmation: 0, topicImportance: 0),
|
|
sourceCount: cached.sourceCount,
|
|
sources: [],
|
|
consensus: cached.consensus,
|
|
conflict: cached.conflict,
|
|
updatedAt: cached.updatedAt,
|
|
createdAt: cached.updatedAt
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
/// Full-bleed black List row with no separator — keeps the flat feed look.
|
|
func plainBlackRow() -> some View {
|
|
self.listRowInsets(EdgeInsets())
|
|
.listRowBackground(Color.black)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|