Sink seen/read stories, fix F1 matching, drop East Africa pill
- Sink already-seen and read stories to the bottom of the feed (new SeenStory model + per-load snapshot so order is stable while scrolling and refreshes sink what you've seen) so fresh content surfaces. - F1 matching: clear F1 headline OR a majority of F1-feed sources, replacing the broad keyword list that leaked crypto in (fia -> "deFIAnt"). Dropped unused matchSources path. - Remove the East Africa pill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ struct JarvisApp: App {
|
|||||||
await notifications.bootstrap()
|
await notifications.bootstrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, SavedStory.self])
|
.modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self,
|
||||||
|
SavedStory.self, SeenStory.self])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,17 @@ final class SavedStory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class SeenStory {
|
||||||
|
@Attribute(.unique) var id: String
|
||||||
|
var seenAt: Date
|
||||||
|
|
||||||
|
init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
self.seenAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class CachedArticle {
|
final class CachedArticle {
|
||||||
@Attribute(.unique) var id: String
|
@Attribute(.unique) var id: String
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ struct SignalFeedView: View {
|
|||||||
@Query private var cachedArticles: [CachedArticle]
|
@Query private var cachedArticles: [CachedArticle]
|
||||||
@Query private var readStories: [ReadStory]
|
@Query private var readStories: [ReadStory]
|
||||||
@Query private var savedStories: [SavedStory]
|
@Query private var savedStories: [SavedStory]
|
||||||
|
@Query private var seenStories: [SeenStory]
|
||||||
@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
|
@State private var selectedInterest: Interest? = nil
|
||||||
@State private var shareStory: StorySummary?
|
@State private var shareStory: StorySummary?
|
||||||
|
/// Snapshot of already-seen story ids, captured per load so the order stays
|
||||||
|
/// stable while scrolling (and refreshes to sink newly-seen ones).
|
||||||
|
@State private var seenSnapshot: Set<String> = []
|
||||||
|
|
||||||
/// 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)) }
|
||||||
@@ -45,16 +49,36 @@ struct SignalFeedView: View {
|
|||||||
} else {
|
} else {
|
||||||
base = []
|
base = []
|
||||||
}
|
}
|
||||||
guard let interest = selectedInterest else {
|
|
||||||
// "All" is news-only: F1 lives solely under its own pill.
|
let filtered: [StorySummary]
|
||||||
return base.filter { !Interest.f1.matches($0) }
|
if let interest = selectedInterest {
|
||||||
}
|
|
||||||
if interest.id == "f1" {
|
if interest.id == "f1" {
|
||||||
return base.filter(interest.matches)
|
filtered = base.filter(interest.matches)
|
||||||
|
} else {
|
||||||
|
// Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it).
|
||||||
|
filtered = base.filter { interest.matches($0) && !Interest.f1.matches($0) }
|
||||||
}
|
}
|
||||||
// Every other pill (incl. Tech/Finance) excludes F1, since the backend
|
} else {
|
||||||
// mis-tags F1 stories into finance/tech/politics.
|
// "All" is news-only: F1 lives solely under its own pill.
|
||||||
return base.filter { interest.matches($0) && !Interest.f1.matches($0) }
|
filtered = base.filter { !Interest.f1.matches($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sink stories you've already read or seen (in a prior load) so fresh
|
||||||
|
// content surfaces and you stop seeing the same articles. Uses a per-load
|
||||||
|
// snapshot so the order is stable while you scroll.
|
||||||
|
let demoted = readStoryIds.union(seenSnapshot)
|
||||||
|
let fresh = filtered.filter { !demoted.contains($0.id) }
|
||||||
|
let old = filtered.filter { demoted.contains($0.id) }
|
||||||
|
return fresh + old
|
||||||
|
}
|
||||||
|
|
||||||
|
private func captureSeenSnapshot() { seenSnapshot = Set(seenStories.map(\.id)) }
|
||||||
|
|
||||||
|
private func markSeen(_ s: StorySummary) {
|
||||||
|
let id = s.id
|
||||||
|
guard !seenStories.contains(where: { $0.id == id }) else { return }
|
||||||
|
modelContext.insert(SeenStory(id: id))
|
||||||
|
try? modelContext.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -218,6 +242,7 @@ struct SignalFeedView: View {
|
|||||||
.listRowBackground(Color.black)
|
.listRowBackground(Color.black)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
markSeen(story)
|
||||||
if story.id == displayStories.last?.id {
|
if story.id == displayStories.last?.id {
|
||||||
Task { await store.loadMore() }
|
Task { await store.loadMore() }
|
||||||
}
|
}
|
||||||
@@ -255,7 +280,9 @@ struct SignalFeedView: View {
|
|||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
.onAppear { captureSeenSnapshot() }
|
||||||
.refreshable {
|
.refreshable {
|
||||||
|
captureSeenSnapshot() // sink what you've already seen
|
||||||
await store.loadStories(refresh: true)
|
await store.loadStories(refresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,25 +188,35 @@ struct Interest: Identifiable, Hashable {
|
|||||||
/// When set, matches the backend topic classification instead of keywords
|
/// When set, matches the backend topic classification instead of keywords
|
||||||
/// (e.g. finance, tech).
|
/// (e.g. finance, tech).
|
||||||
var topicSlug: String? = nil
|
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 {
|
func matches(_ s: StorySummary) -> Bool {
|
||||||
|
if id == "f1" { return Interest.isFormula1(s) }
|
||||||
if let topicSlug {
|
if let topicSlug {
|
||||||
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame
|
return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame
|
||||||
}
|
}
|
||||||
// Headline only — the backend's summaries are polluted by over-clustering
|
// Headline only — the backend's summaries/outlet names are polluted by
|
||||||
// (a Congo headline can carry a South-Africa summary), so they're unsafe
|
// over-clustering (a Congo headline can carry a South-Africa summary or an
|
||||||
// to match on. F1 additionally trusts its (clean) source names.
|
// SA outlet), so they're unsafe to match on.
|
||||||
var hay = s.headline
|
let hay = s.headline.lowercased()
|
||||||
if matchSources { hay += " " + s.sources.map(\.name).joined(separator: " ") }
|
|
||||||
hay = hay.lowercased()
|
|
||||||
if excludeKeywords.contains(where: { hay.contains($0) }) { return false }
|
if excludeKeywords.contains(where: { hay.contains($0) }) { return false }
|
||||||
return keywords.contains { hay.contains($0) }
|
return keywords.contains { hay.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F1 = a clear F1 headline, or a majority of F1-feed sources. A single F1
|
||||||
|
/// source isn't enough — the over-clustered backend sometimes attaches one to
|
||||||
|
/// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt".
|
||||||
|
static func isFormula1(_ s: StorySummary) -> Bool {
|
||||||
|
let h = s.headline.lowercased()
|
||||||
|
if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix")
|
||||||
|
|| h.hasPrefix("f1 ") || h.hasSuffix(" f1") || h.contains(" f1 ") || h.contains("f1:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let feeds = ["f1", "racefans", "motorsport", "autosport", "planetf1", "the race", "formula 1"]
|
||||||
|
let srcs = s.sources
|
||||||
|
guard !srcs.isEmpty else { return false }
|
||||||
|
let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count
|
||||||
|
return Double(n) / Double(srcs.count) >= 0.5
|
||||||
|
}
|
||||||
|
|
||||||
// Region keywords are place/person names found in the story text — never
|
// Region keywords are place/person names found in the story text — never
|
||||||
// outlet names (which are unreliable due to backend over-clustering).
|
// outlet names (which are unreliable due to backend over-clustering).
|
||||||
private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni",
|
private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni",
|
||||||
@@ -216,22 +226,12 @@ struct Interest: Identifiable, Hashable {
|
|||||||
"eskom", "gauteng", "western cape", "soweto",
|
"eskom", "gauteng", "western cape", "soweto",
|
||||||
"drakensberg", "mbalula", "zuma"]
|
"drakensberg", "mbalula", "zuma"]
|
||||||
|
|
||||||
static let f1 = Interest(id: "f1", label: "F1",
|
static let f1 = Interest(id: "f1", label: "F1") // matching handled by isFormula1
|
||||||
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.
|
/// Pinned filter pills, in order.
|
||||||
static let pinned: [Interest] = [
|
static let pinned: [Interest] = [
|
||||||
f1,
|
f1,
|
||||||
Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords),
|
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: "south-africa", label: "South Africa", keywords: southAfricaKeywords),
|
||||||
Interest(id: "finance", label: "Finance", topicSlug: "finance"),
|
Interest(id: "finance", label: "Finance", topicSlug: "finance"),
|
||||||
Interest(id: "tech", label: "Tech", topicSlug: "tech"),
|
Interest(id: "tech", label: "Tech", topicSlug: "tech"),
|
||||||
|
|||||||
Reference in New Issue
Block a user