Turn "All" into a sectioned news front page
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Instead of one long firehose, "All" now renders Apple/Google-News-style:
a Top Stories lead block, then category sections (World, Politics, Business,
AI, Technology, Africa, Sport, Science, Entertainment, Health, Lifestyle) with
the top few stories each and a "See all" jump to the matching pill. Sections are
built from backend tags[]; specific pills keep the flat list. "All" preloads up
to ~100 stories to fill sections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-20 18:34:14 +03:00
parent 22b859dc7b
commit da43399ead
2 changed files with 111 additions and 18 deletions

View File

@@ -68,6 +68,27 @@ struct SignalFeedView: View {
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<SeenStory>()))?.map(\.id) ?? []
seenSnapshot = Set(ids)
@@ -99,7 +120,7 @@ struct SignalFeedView: View {
header
syncBar
topicPills
columnHeaders
if !isDigest { columnHeaders } // sections carry their own headers
Divider().overlay(Palette.hairline)
feedList
}
@@ -127,13 +148,17 @@ struct SignalFeedView: View {
}
.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
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
}
} else {
// Sparse pill: pull a few more pages until something matches.
while filteredStories.isEmpty && store.hasMore && pages < 8 {
await store.loadMore()
pages += 1
await store.loadMore(); pages += 1
}
}
}
}
@@ -235,18 +260,10 @@ struct SignalFeedView: View {
List {
if mainStories.isEmpty && readItems.isEmpty {
emptyState.plainBlackRow()
} else if isDigest {
digestContent
} 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
flatContent
}
}
.listStyle(.plain)
@@ -260,6 +277,51 @@ struct SignalFeedView: View {
}
}
/// 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)
ForEach(d.top) { 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) -> some View {
NavigationLink(value: story) {

View File

@@ -224,4 +224,35 @@ extension StorySummary {
func matches(_ pill: StoryPill) -> Bool {
pill == .all || !effectiveTags.isDisjoint(with: pill.slugs)
}
func inSection(_ section: NewsSection) -> Bool {
!effectiveTags.isDisjoint(with: section.slugs)
}
}
// MARK: - News sections (the "All" front page)
//
// Groups the firehose into Apple/Google-News-style sections. A story lands in
// the first section (by this order) whose slugs it carries; `pill` powers "See all".
struct NewsSection: Identifiable {
let id: String
let label: String
let slugs: Set<String>
let pill: StoryPill?
static let sections: [NewsSection] = [
.init(id: "us", label: "US", slugs: ["united-states"], pill: .unitedStates),
.init(id: "world", label: "World", slugs: ["world-news", "news", "news-and-current-affairs"], pill: nil),
.init(id: "politics", label: "Politics", slugs: ["politics", "human-rights", "investigative"], pill: nil),
.init(id: "business", label: "Business", slugs: ["business", "finance", "fintech", "cryptocurrency", "blockchain", "entrepreneur", "startups", "real-estate"], pill: nil),
.init(id: "ai", label: "AI", slugs: ["artificial-intelligence", "machine-learning", "robotics"], pill: .ai),
.init(id: "technology", label: "Technology", slugs: ["technology", "programming-and-software-development", "cybersecurity", "security", "web-design-and-ui-ux", "wordpress-and-web-development", "privacy-and-data-protection", "cloud-computing"], pill: .tech),
.init(id: "africa", label: "Africa", slugs: ["africa", "east-africa", "south-africa", "uganda"], pill: nil),
.init(id: "sport", label: "Sport", slugs: ["sports", "esports", "formula-1"], pill: .sport),
.init(id: "science", label: "Science", slugs: ["science", "astronomy", "environment", "green-technology", "sustainability"], pill: nil),
.init(id: "entertainment", label: "Entertainment", slugs: ["entertainment", "music", "pop-culture", "video-game", "arts-and-culture", "books-and-literature"], pill: nil),
.init(id: "health", label: "Health", slugs: ["health", "health-and-wellness", "mental-health"], pill: nil),
.init(id: "lifestyle", label: "Lifestyle", slugs: ["lifestyle", "food-and-drink", "travel", "gardening", "outdoor-and-hiking", "parenting-and-family", "interior-design-and-home-decor", "photography", "self-improvement-and-personal-development", "education", "e-learning"], pill: nil),
]
}