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:
Robin Kutesa
2026-06-19 01:00:39 +03:00
parent 9662c2abf6
commit 87bfb234c2
3 changed files with 132 additions and 19 deletions

View File

@@ -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")