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]
|
@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,23 +147,15 @@ 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)
|
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +164,18 @@ struct SignalFeedView: View {
|
|||||||
.padding(.bottom, 14)
|
.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
|
// MARK: - Column headers
|
||||||
|
|
||||||
private var columnHeaders: some View {
|
private var columnHeaders: some View {
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user