Add feed summary preview and pinned interest filters
- Show a 6-line cross-source summary preview in each feed row. - Pinned, mutually-exclusive filter pills: All, F1, Uganda, East Africa, South Africa, Finance, Tech. "All" is news-only (F1 lives only under F1); regions don't overlap (East Africa excludes Uganda & South Africa). - Categorization correctness: regions match the headline only (the backend's summaries/outlets are polluted by over-clustering), and F1 takes precedence over every other pill since the backend mis-tags F1 as tech/finance/politics. - Finance/Tech match the backend topic; sparse filters auto-pull more pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ struct SignalFeedView: View {
|
||||
@Query private var readStories: [ReadStory]
|
||||
@State private var showFeeds = false
|
||||
@State private var showSettings = false
|
||||
@State private var selectedInterest: Interest? = nil
|
||||
|
||||
/// Stories that have full article content cached → eligible for the green dot.
|
||||
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
||||
@@ -33,15 +34,24 @@ struct SignalFeedView: View {
|
||||
|
||||
/// Live stories when online; cached fallback when the feed is empty & offline.
|
||||
private var displayStories: [StorySummary] {
|
||||
let base: [StorySummary]
|
||||
if !store.stories.isEmpty {
|
||||
return store.stories.sorted(by: StorySummary.feedOrder)
|
||||
base = store.stories.sorted(by: StorySummary.feedOrder)
|
||||
} else if !ws.connectionState.isLive {
|
||||
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||
} else {
|
||||
base = []
|
||||
}
|
||||
if !ws.connectionState.isLive {
|
||||
return cachedStories
|
||||
.map(StorySummary.init(cached:))
|
||||
.sorted(by: StorySummary.feedOrder)
|
||||
guard let interest = selectedInterest else {
|
||||
// "All" is news-only: F1 lives solely under its own pill.
|
||||
return base.filter { !Interest.f1.matches($0) }
|
||||
}
|
||||
return []
|
||||
if interest.id == "f1" {
|
||||
return base.filter(interest.matches)
|
||||
}
|
||||
// Every other pill (incl. Tech/Finance) excludes F1, since the backend
|
||||
// mis-tags F1 stories into finance/tech/politics.
|
||||
return base.filter { interest.matches($0) && !Interest.f1.matches($0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -76,6 +86,16 @@ struct SignalFeedView: View {
|
||||
.onChange(of: store.stories) { _, newValue in
|
||||
cacheStories(newValue)
|
||||
}
|
||||
.task(id: selectedInterest?.id) {
|
||||
// A filter active but nothing matched in the loaded set → pull a few
|
||||
// more pages so sparse interests still populate.
|
||||
guard selectedInterest != nil else { return }
|
||||
var pages = 0
|
||||
while displayStories.isEmpty && store.hasMore && pages < 8 {
|
||||
await store.loadMore()
|
||||
pages += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -127,23 +147,15 @@ struct SignalFeedView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Topic pills
|
||||
// MARK: - Pinned interest pills
|
||||
|
||||
private var topicPills: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Topic.filters, id: \.label) { filter in
|
||||
let selected = store.selectedTopic == filter.slug
|
||||
Button {
|
||||
Task { await store.setTopic(filter.slug) }
|
||||
} label: {
|
||||
Text(filter.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())
|
||||
pill("All", selected: selectedInterest == nil) { selectedInterest = nil }
|
||||
ForEach(Interest.pinned) { interest in
|
||||
pill(interest.label, selected: selectedInterest?.id == interest.id) {
|
||||
selectedInterest = (selectedInterest?.id == interest.id) ? nil : interest
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +164,18 @@ struct SignalFeedView: View {
|
||||
.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 {
|
||||
@@ -228,6 +252,19 @@ struct SignalFeedView: View {
|
||||
Text("No cached stories to show")
|
||||
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
refreshButton("Retry")
|
||||
} else if let interest = selectedInterest {
|
||||
emptyIcon("line.3.horizontal.decrease.circle")
|
||||
Text("No \(interest.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")
|
||||
|
||||
@@ -31,6 +31,16 @@ struct StoryRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Summary preview — the gist, without opening the story.
|
||||
if !story.summary.isEmpty {
|
||||
Text(story.summary)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(Color(hex: "8A8A8A"))
|
||||
.lineLimit(6)
|
||||
.lineSpacing(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// Source chips
|
||||
if !sourceNames.isEmpty {
|
||||
SourceChips(names: sourceNames, total: story.sourceCount)
|
||||
|
||||
Reference in New Issue
Block a user