// 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 = [] /// 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 { Set(cachedArticles.map(\.storyId)) } private var readStoryIds: Set { Set(readStories.map(\.id)) } private var savedStoryIds: Set { 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()))?.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(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(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(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())) ?? [] 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() } // 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) } }