Turn "All" into a sectioned news front page
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:
@@ -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
|
||||
while filteredStories.isEmpty && store.hasMore && pages < 8 {
|
||||
await store.loadMore()
|
||||
pages += 1
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user