// 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: typewriter font, lowercase j in orange, orange dot on i. struct JarvisWordmark: View { var size: CGFloat = 30 private var font: Font { .custom("Courier New", size: size).bold() } var body: some View { HStack(spacing: 0) { Text("j").font(font).foregroundStyle(Palette.orange) Text("arv").font(font).foregroundStyle(.white) Text("is").font(font).foregroundStyle(.white) } } } 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) } } /// "All" shows a sectioned front page; specific pills show a flat list. private var isDigest: Bool { selectedPill == .all } /// Front-page digest: Top Stories + up to 3 per category section, deduped. private var digest: (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) { let unread = filteredStories.filter { !readStoryIds.contains($0.id) } let top = Array(unread.prefix(5)) var pool = Array(unread.dropFirst(5)) var out: [(NewsSection, [StorySummary])] = [] for section in NewsSection.sections { var picked: [StorySummary] = [], rest: [StorySummary] = [] for s in pool { if picked.count < 3 && s.inSection(section) { picked.append(s) } else { rest.append(s) } } pool = rest if !picked.isEmpty { out.append((section, picked)) } } return (top, out) } 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 if !isDigest { columnHeaders } // sections carry their own headers 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)) } } .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } .task { CacheMaintenance.prune(modelContext) } // bound the caches on launch .task(id: selectedPill) { await store.setPill(selectedPill) var pages = 0 if selectedPill == .all { // Front page: pull enough stories to fill the sections. while store.stories.count < 100 && store.hasMore && pages < 5 { 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 if isDigest { digestContent } else { flatContent } } .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color.black) .animation(.snappy, value: showRead) .animation(.snappy, value: readStories.count) .onAppear { captureSeenSnapshot() } .refreshable { // 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) } } /// Flat list for a specific pill. @ViewBuilder private var flatContent: some View { ForEach(mainStories) { mainRow($0) } if mainStories.isEmpty { caughtUpRow.plainBlackRow() } if store.isLoadingMore { ProgressView().tint(Palette.orange) .frame(maxWidth: .infinity).padding(.vertical, 20).plainBlackRow() } readShelf } /// Sectioned front page for "All". @ViewBuilder private var digestContent: some View { let d = digest sectionHeader("TOP STORIES", pill: nil) if let lead = d.top.first { mainRow(lead, hero: true) ForEach(d.top.dropFirst()) { mainRow($0) } } ForEach(d.sections, id: \.0.id) { section, stories in sectionHeader(section.label.uppercased(), pill: section.pill) ForEach(stories) { mainRow($0) } } readShelf } private func sectionHeader(_ label: String, pill: StoryPill?) -> some View { HStack(alignment: .firstTextBaseline) { Text(label) .font(.system(size: 13, weight: .heavy, design: .monospaced)) .kerning(0.6) .foregroundStyle(.white) Rectangle().fill(Palette.orange).frame(width: 18, height: 2) Spacer() if let pill { Button { selectedPill = pill } label: { HStack(spacing: 2) { Text("See all"); Image(systemName: "chevron.right").font(.system(size: 10, weight: .bold)) } .font(.system(size: 12, weight: .bold)) .foregroundStyle(Palette.orange) } } } .padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 10) .plainBlackRow() } @ViewBuilder private func mainRow(_ story: StorySummary, hero: Bool = false) -> some View { NavigationLink(value: story) { if hero { heroCard(story) } else { StoryRowView(story: story, isCached: cachedStoryIds.contains(story.id)) } } .listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets()) .listRowBackground(Color.black) .listRowSeparator(.hidden) .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) } } /// Apple-News-style lead card for the #1 Top Story — flat surface, big type. private func heroCard(_ story: StorySummary) -> some View { HStack(alignment: .top, spacing: 0) { SignalStripe(score: story.signalScore, width: 4) VStack(alignment: .leading, spacing: 11) { HStack(alignment: .firstTextBaseline) { Text(Topic.label(story.topic).uppercased()) .font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8) .foregroundStyle(Color(hex: "8A8A8A")) Spacer() Text("\(story.signalScore)") .font(.system(size: 21, weight: .heavy, design: .monospaced)) .foregroundStyle(Signal.scoreColor(story.signalScore)) } Text(story.headline) .font(.system(size: 24, weight: .heavy)) .foregroundStyle(.white) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) if !story.summary.isEmpty { Text(story.summary) .font(.system(size: 14)) .foregroundStyle(Color(hex: "9A9A9A")) .lineLimit(3).lineSpacing(2) .fixedSize(horizontal: false, vertical: true) } if !story.sources.isEmpty { SourceChips(names: story.sources.map(\.name), total: story.sourceCount) } Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago") .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(Color(hex: "666666")) } .padding(16) Spacer(minLength: 0) } .background(Palette.surface) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5)) } 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() withAnimation(.snappy) { showRead = true } } } 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) } }