feat: East Africa + Southern Africa pills with regional sub-sections
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

- East Africa pill: merges Uganda + east-africa tags; Uganda pinned first via sub-sections
- Southern Africa pill: covers SA, Namibia, Botswana, Zimbabwe, Zambia, Mozambique; SA/Namibia/Botswana prioritized
- StoryStore.setPill now resets lastSyncedAt to bypass 30s rate-limit on filter switch
- sectionSupplement background fetch populates sparse regional sections in All digest
- SignalFeedView digestContent: flat hero+scroll path for pills without subSections (was showing max 5)
- Theme: removed standalone Uganda pill, wired new sub-section layouts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-22 02:33:33 +03:00
parent 6677033faa
commit 595f21fb6c
27 changed files with 635 additions and 293 deletions

View File

@@ -33,6 +33,8 @@ struct SignalFeedView: View {
/// Live stories when connected; cached fallback while loading or offline so
/// switching to Tech / AI / F1 shows cached cards instantly.
/// Before the first sync of this session completes, only unread stories are
/// surfaced read content is irrelevant until we know there's nothing newer.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
@@ -40,7 +42,11 @@ struct SignalFeedView: View {
} else {
base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
}
return base.filter { $0.matches(selectedPill) }
let matched = base.filter { $0.matches(selectedPill) }
if store.lastSyncedAt == nil {
return matched.filter { !readStoryIds.contains($0.id) }
}
return matched
}
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
@@ -59,11 +65,14 @@ struct SignalFeedView: View {
/// Every pill shows the card layout sections are scoped to "All".
private var isDigest: Bool { true }
/// Front-page digest: Top Stories + up to 3 per category section, deduped.
/// Front-page digest: Top Stories + up to 4 per category section, deduped.
/// Sections that score poorly in the global top-100 are supplemented from
/// per-section targeted fetches held in store.sectionSupplement.
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
let top = Array(unread.prefix(5))
var pool = Array(unread.dropFirst(5))
var usedIds = Set(top.map(\.id))
var out: [(NewsSection, [StorySummary])] = []
for section in sections {
var picked: [StorySummary] = [], rest: [StorySummary] = []
@@ -72,6 +81,15 @@ struct SignalFeedView: View {
else { rest.append(s) }
}
pool = rest
// Supplement from section-specific fetch when main pool is sparse
if picked.count < 2, let extra = store.sectionSupplement[section.id] {
for s in extra where !usedIds.contains(s.id) && s.inSection(section) {
picked.append(s)
usedIds.insert(s.id)
if picked.count == 4 { break }
}
}
usedIds.formUnion(picked.map(\.id))
if !picked.isEmpty { out.append((section, picked)) }
}
return (top, out)
@@ -102,7 +120,7 @@ struct SignalFeedView: View {
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
header
@@ -134,39 +152,35 @@ struct SignalFeedView: View {
}
.task { CacheMaintenance.prune(modelContext) } // bound the caches on launch
.task(id: selectedPill) {
await store.setPill(selectedPill)
var pages = 0
// All and Tech both use multi-section layouts that need enough stories.
if selectedPill == .all || selectedPill == .tech {
while store.stories.count < 100 && store.hasMore && pages < 5 {
await store.loadMore(); pages += 1
}
}
await store.setPill(selectedPill) // paginates to 100 stories internally
}
}
// MARK: - Header
private var header: some View {
HStack(alignment: .center) {
JarvisWordmark(size: 30)
HStack(alignment: .center, spacing: 0) {
JarvisWordmark(size: 26)
Spacer()
ConnectionBanner(state: ws.connectionState)
Button {
showFeeds = true
} label: {
Image(systemName: "dot.radiowaves.up.forward")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: "888888"))
.frame(width: 44, height: 44)
}
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(Color(hex: "888888"))
.frame(width: 44, height: 44)
// Connection + controls as one coherent surface
HStack(spacing: 2) {
ConnectionBanner(state: ws.connectionState)
Button {
showFeeds = true
} label: {
Image(systemName: "dot.radiowaves.up.forward")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40)
}
Button {
showSettings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Palette.secondaryText)
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 16)
@@ -175,18 +189,33 @@ struct SignalFeedView: View {
// MARK: - Sync bar
/// True while the first sync of this session hasn't completed yet, OR while
/// a fetch is actively in flight. Used to gate the "caught up" state so we
/// never declare victory before we've actually checked the server.
private var isSyncing: Bool { store.isLoading || store.lastSyncedAt == nil }
private var syncBar: some View {
Text(syncText)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "5A5A5A"))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.bottom, 12)
HStack(spacing: 5) {
if isSyncing {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.mini)
.tint(Palette.orange)
}
Text(isSyncing ? "Syncing signals…" : syncText)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "5A5A5A"))
.animation(.easeInOut(duration: 0.15), value: store.isLoading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
private var syncText: String {
let ago = syncedMinutesAgo(store.lastSyncedAt)
let phrase = (ago == "just now" || ago == "never") ? ago : "\(ago) ago"
guard let last = store.lastSyncedAt else { return "Loading from cache…" }
let ago = last.timeAgoShort()
let phrase = ago == "just now" ? ago : "\(ago) ago"
if ws.connectionState.isLive {
return "Synced \(phrase) · \(cachedStoryIds.count) cached"
} else {
@@ -213,10 +242,10 @@ struct SignalFeedView: View {
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)
.font(.system(size: 12, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? .black : Palette.secondaryText)
.padding(.horizontal, 13)
.frame(height: 28)
.background(selected ? Palette.orange : Palette.surface)
.clipShape(Capsule())
}
@@ -233,7 +262,7 @@ struct SignalFeedView: View {
.kerning(0.8)
}
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
.foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
@@ -246,50 +275,84 @@ struct SignalFeedView: View {
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.black)
.background(Palette.background)
.animation(.snappy, value: showRead)
.animation(.snappy, value: readStories.count)
.onAppear { captureSeenSnapshot() }
.onAppear {
captureSeenSnapshot()
// If the cache already shows all-read on first render, open the
// shelf immediately so the user sees their previous stories.
if !readItems.isEmpty && mainStories.isEmpty { showRead = true }
}
// Expand shelf when loading finishes and there are still no unread stories.
.onChange(of: store.isLoading) { _, loading in
if !loading, !readItems.isEmpty, mainStories.isEmpty {
withAnimation(.snappy) { showRead = true }
}
}
// Expand shelf if SwiftData @Query hydrates after the first render and
// all cached stories turn out to already be read.
.onChange(of: readItems.count) { _, count in
if count > 0, mainStories.isEmpty, !showRead {
showRead = true
}
}
.refreshable {
seenSnapshot = []
seenTracker.ids = []
let new = await store.loadStories(refresh: true)
// Nothing new from server pull in any stories cached by background
// refresh that aren't already in the live feed, then reshuffle.
if new == 0 {
let liveIds = Set(store.stories.map(\.id))
let extra = cachedStories
.filter { !liveIds.contains($0.id) }
.map(StorySummary.init(cached:))
if !extra.isEmpty {
await store.mergeStories(extra)
}
await store.loadFeedFull()
// Merge any stories cached by background refresh that aren't in
// the live feed yet mergeStories deduplicates, so always safe.
let liveIds = Set(store.stories.map(\.id))
let extra = cachedStories
.filter { !liveIds.contains($0.id) }
.map(StorySummary.init(cached:))
if !extra.isEmpty {
await store.mergeStories(extra)
}
}
}
/// Card layout for every pill. "All" gets full multi-section front page;
/// topic pills get a hero card + flat list within that topic.
/// Card layout for every pill.
/// "All" multi-section digest. Tech (sub-sections) sub-section digest.
/// Everything else hero card + flat scroll of all stories for that pill.
@ViewBuilder private var digestContent: some View {
let activeSections = selectedPill == .all
? NewsSection.sections
: (selectedPill.subSections ?? [])
let d = makeDigest(sections: activeSections)
let headerLabel = selectedPill == .all ? "TOP STORIES" : selectedPill.label.uppercased()
if selectedPill == .all {
let d = makeDigest(sections: NewsSection.sections)
sectionHeader("TOP STORIES", pill: nil)
pillContent(top: d.top)
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
} else if let subSections = selectedPill.subSections {
let d = makeDigest(sections: subSections)
sectionHeader(selectedPill.label.uppercased(), pill: nil)
pillContent(top: d.top)
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
} else {
// Flat list: hero + all stories for this pill
sectionHeader(selectedPill.label.uppercased(), pill: nil)
pillContent(top: mainStories, flat: true)
}
readShelf
}
sectionHeader(headerLabel, pill: nil)
if let lead = d.top.first {
/// Shared hero + body rows. `flat` = render all items; otherwise only top 5.
@ViewBuilder private func pillContent(top stories: [StorySummary], flat: Bool = false) -> some View {
if let lead = stories.first {
mainRow(lead, hero: true)
ForEach(d.top.dropFirst()) { mainRow($0) }
let rest = flat ? Array(stories.dropFirst()) : Array(stories.dropFirst().prefix(4))
ForEach(rest) { mainRow($0) }
} else if !filteredStories.isEmpty {
if isSyncing { checkingRow.plainBlackRow() }
else { caughtUpRow.plainBlackRow() }
} else {
emptyState.plainBlackRow()
}
// "All" and pills with sub-sections (Tech) get a sectioned breakdown.
ForEach(d.sections, id: \.0.id) { section, stories in
sectionHeader(section.label.uppercased(), pill: section.pill)
ForEach(stories) { mainRow($0) }
}
readShelf
}
private func sectionHeader(_ label: String, pill: StoryPill?) -> some View {
@@ -297,7 +360,7 @@ struct SignalFeedView: View {
Text(label)
.font(.system(size: 13, weight: .heavy, design: .monospaced))
.kerning(0.6)
.foregroundStyle(.white)
.foregroundStyle(Palette.primaryText)
Rectangle().fill(Palette.orange).frame(width: 18, height: 2)
Spacer()
if let pill {
@@ -326,7 +389,7 @@ struct SignalFeedView: View {
}
}
.listRowInsets(hero ? EdgeInsets(top: 2, leading: 16, bottom: 12, trailing: 16) : EdgeInsets())
.listRowBackground(Color.black)
.listRowBackground(Palette.background)
.listRowSeparator(.hidden)
.onAppear {
markSeen(story)
@@ -358,24 +421,24 @@ struct SignalFeedView: View {
HStack(alignment: .top, spacing: 0) {
SignalStripe(score: story.signalScore, width: 4)
VStack(alignment: .leading, spacing: 11) {
HStack(alignment: .firstTextBaseline) {
HStack(alignment: .center) {
Text(Topic.label(story.topic).uppercased())
.font(.system(size: 10, weight: .bold, design: .monospaced)).kerning(0.8)
.foregroundStyle(Color(hex: "8A8A8A"))
.foregroundStyle(Palette.secondaryText)
Spacer()
Text("\(story.signalScore)")
.font(.system(size: 21, weight: .heavy, design: .monospaced))
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundStyle(Signal.scoreColor(story.signalScore))
}
Text(story.headline)
.font(.system(size: 24, weight: .heavy))
.foregroundStyle(.white)
.font(.system(size: 25, weight: .bold, design: .default))
.foregroundStyle(Palette.primaryText)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
if !story.summary.isEmpty {
Text(story.summary)
.font(.system(size: 14))
.foregroundStyle(Color(hex: "9A9A9A"))
.font(.system(size: 16, weight: .regular, design: .default))
.foregroundStyle(Palette.bodyText)
.lineLimit(3).lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
}
@@ -384,7 +447,7 @@ struct SignalFeedView: View {
}
Text("\(story.sourceCount) sources · \(story.updatedAt.timeAgoShort()) ago")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
.foregroundStyle(Palette.tertiaryText)
}
.padding(16)
Spacer(minLength: 0)
@@ -394,11 +457,27 @@ struct SignalFeedView: View {
.overlay(RoundedRectangle(cornerRadius: 16).stroke(Palette.surface2, lineWidth: 0.5))
}
/// Shown when all cached stories are read AND the first sync hasn't finished yet.
/// Keeps the UI honest we haven't confirmed "no new content" until the server responds.
private var checkingRow: some View {
VStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.mini)
.tint(Palette.orange)
Text("Checking for new signals…")
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(Palette.tertiaryText)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 50)
}
private var caughtUpRow: some View {
VStack(spacing: 10) {
Image(systemName: "checkmark.circle")
.font(.system(size: 32)).foregroundStyle(Color(hex: "3E7E3E"))
Text("You're all caught up").font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
Text("You're all caught up").font(.system(size: 16, weight: .semibold, design: .default)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh for more")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
}
@@ -423,7 +502,7 @@ struct SignalFeedView: View {
Image(systemName: showRead ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Color(hex: "666666"))
.foregroundStyle(Palette.tertiaryText)
.padding(.horizontal, 16).padding(.vertical, 16)
.contentShape(Rectangle())
}
@@ -455,8 +534,8 @@ struct SignalFeedView: View {
private var emptyState: some View {
VStack(spacing: 12) {
if store.isLoading {
// Actively polling the server.
if isSyncing {
// First sync hasn't completed yet, or a fetch is actively in flight.
ProgressView().tint(Palette.orange)
Text("Polling signals…")
.font(.system(size: 14, weight: .heavy))
@@ -466,7 +545,7 @@ struct SignalFeedView: View {
// Reached the empty state because the fetch failed.
emptyIcon("exclamationmark.triangle", color: Color(hex: "C25555"))
Text("Couldn't reach the server")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text(error.errorDescription ?? "Unknown error")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
.multilineTextAlignment(.center)
@@ -474,14 +553,14 @@ struct SignalFeedView: View {
refreshButton("Retry")
} else if !ws.connectionState.isLive {
emptyIcon("wifi.slash")
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
Text("Offline").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry")
} else if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle")
Text("No \(selectedPill.label) stories yet")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
.font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
@@ -494,7 +573,7 @@ struct SignalFeedView: View {
} else {
// Connected, loaded, genuinely nothing yet.
emptyIcon("antenna.radiowaves.left.and.right")
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
Text("No signals yet").font(.system(size: 15, weight: .heavy)).foregroundStyle(Palette.primaryText)
Text("Pull to refresh")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Refresh")
@@ -505,7 +584,7 @@ struct SignalFeedView: View {
.padding(.top, 80)
}
private func emptyIcon(_ name: String, color: Color = Color(hex: "333333")) -> some View {
private func emptyIcon(_ name: String, color: Color = Palette.mutedText) -> some View {
Image(systemName: name).font(.system(size: 30)).foregroundStyle(color)
}
@@ -513,13 +592,13 @@ struct SignalFeedView: View {
if let host = settings.host {
Text(host)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
.foregroundStyle(Palette.tertiaryText)
}
}
private func refreshButton(_ title: String) -> some View {
Button {
Task { await store.loadStories(refresh: true) }
Task { await store.loadFeedFull() }
} label: {
Text(title)
.font(.system(size: 14, weight: .bold)).foregroundStyle(.black)
@@ -611,7 +690,7 @@ extension StorySummary {
consensus: cached.consensus,
conflict: cached.conflict,
updatedAt: cached.updatedAt,
createdAt: cached.updatedAt
createdAt: cached.createdAt
)
}
}
@@ -620,7 +699,7 @@ private extension View {
/// Full-bleed black List row with no separator keeps the flat feed look.
func plainBlackRow() -> some View {
self.listRowInsets(EdgeInsets())
.listRowBackground(Color.black)
.listRowBackground(Palette.background)
.listRowSeparator(.hidden)
}
}