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] @Query private var readStories: [ReadStory]
@State private var showFeeds = false @State private var showFeeds = false
@State private var showSettings = false @State private var showSettings = false
@State private var selectedInterest: Interest? = nil
/// Stories that have full article content cached eligible for the green dot. /// Stories that have full article content cached eligible for the green dot.
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) } 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. /// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] { private var displayStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty { 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 { guard let interest = selectedInterest else {
return cachedStories // "All" is news-only: F1 lives solely under its own pill.
.map(StorySummary.init(cached:)) return base.filter { !Interest.f1.matches($0) }
.sorted(by: StorySummary.feedOrder)
} }
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 { var body: some View {
@@ -76,6 +86,16 @@ struct SignalFeedView: View {
.onChange(of: store.stories) { _, newValue in .onChange(of: store.stories) { _, newValue in
cacheStories(newValue) 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 // MARK: - Header
@@ -127,17 +147,26 @@ struct SignalFeedView: View {
} }
} }
// MARK: - Topic pills // MARK: - Pinned interest pills
private var topicPills: some View { private var topicPills: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(Topic.filters, id: \.label) { filter in pill("All", selected: selectedInterest == nil) { selectedInterest = nil }
let selected = store.selectedTopic == filter.slug ForEach(Interest.pinned) { interest in
Button { pill(interest.label, selected: selectedInterest?.id == interest.id) {
Task { await store.setTopic(filter.slug) } selectedInterest = (selectedInterest?.id == interest.id) ? nil : interest
} label: { }
Text(filter.label) }
}
.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)) .font(.system(size: 13, weight: .bold))
.foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) .foregroundStyle(selected ? .black : Color(hex: "AAAAAA"))
.padding(.horizontal, 14) .padding(.horizontal, 14)
@@ -146,11 +175,6 @@ struct SignalFeedView: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
}
.padding(.horizontal, 16)
}
.padding(.bottom, 14)
}
// MARK: - Column headers // MARK: - Column headers
@@ -228,6 +252,19 @@ struct SignalFeedView: View {
Text("No cached stories to show") Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) .font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry") 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 { } else {
// Connected, loaded, genuinely nothing yet. // Connected, loaded, genuinely nothing yet.
emptyIcon("antenna.radiowaves.left.and.right") emptyIcon("antenna.radiowaves.left.and.right")

View File

@@ -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 // Source chips
if !sourceNames.isEmpty { if !sourceNames.isEmpty {
SourceChips(names: sourceNames, total: story.sourceCount) SourceChips(names: sourceNames, total: story.sourceCount)

View File

@@ -171,3 +171,69 @@ extension StorySummary {
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
} }
} }
// MARK: - Pinned interest filters (client-side)
//
// The backend only classifies topics as finance/tech/politics/africa, so these
// personal filters match keywords in the headline, summary, and source names of
// the loaded stories instead of calling the API.
struct Interest: Identifiable, Hashable {
let id: String
let label: String
var keywords: [String] = []
/// If any of these appear, the story is NOT this interest used to keep the
/// region pills exclusive (East Africa excludes Uganda & South Africa).
var excludeKeywords: [String] = []
/// When set, matches the backend topic classification instead of keywords
/// (e.g. finance, tech).
var topicSlug: String? = nil
/// Whether to consider source/outlet names when matching. True only for F1
/// (its sources are the signal). Regions must NOT an outlet's location is
/// not the story's location (e.g. a Congo story carried by an SA outlet).
var matchSources: Bool = false
func matches(_ s: StorySummary) -> Bool {
if let topicSlug {
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame
}
// Headline only the backend's summaries are polluted by over-clustering
// (a Congo headline can carry a South-Africa summary), so they're unsafe
// to match on. F1 additionally trusts its (clean) source names.
var hay = s.headline
if matchSources { hay += " " + s.sources.map(\.name).joined(separator: " ") }
hay = hay.lowercased()
if excludeKeywords.contains(where: { hay.contains($0) }) { return false }
return keywords.contains { hay.contains($0) }
}
// Region keywords are place/person names found in the story text never
// outlet names (which are unreliable due to backend over-clustering).
private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni",
"entebbe", "jinja", "gulu"]
private static let southAfricaKeywords = ["south africa", "south african", "johannesburg",
"cape town", "pretoria", "durban", "ramaphosa",
"eskom", "gauteng", "western cape", "soweto",
"drakensberg", "mbalula", "zuma"]
static let f1 = Interest(id: "f1", label: "F1",
keywords: ["formula 1", "formula one", "grand prix", "f1", "verstappen", "hamilton",
"mclaren", "ferrari", "red bull", "racefans", "motorsport", "autosport",
"pole position", "pirelli", "qualifying", "fia", "planetf1"],
matchSources: true)
/// Pinned filter pills, in order. Regions are mutually exclusive.
static let pinned: [Interest] = [
f1,
Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords),
Interest(id: "east-africa", label: "East Africa",
keywords: ["east africa", "eastafrican", "kenya", "kenyan", "nairobi",
"tanzania", "tanzanian", "dar es salaam", "rwanda", "rwandan",
"kigali", "ethiopia", "ethiopian", "addis ababa", "burundi",
"south sudan", "juba"],
excludeKeywords: ugandaKeywords + southAfricaKeywords),
Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords),
Interest(id: "finance", label: "Finance", topicSlug: "finance"),
Interest(id: "tech", label: "Tech", topicSlug: "tech"),
]
}