- 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>
329 lines
11 KiB
Swift
329 lines
11 KiB
Swift
// FeedManagerView.swift
|
|
// Jarvis — feed health & management. Healthy vs. needs-attention, live health
|
|
// updates over WebSocket, swipe to delete, add-feed sheet.
|
|
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class FeedManagerViewModel: ObservableObject {
|
|
@Published var feeds: [Feed] = []
|
|
@Published var isLoading = false
|
|
@Published var error: String?
|
|
|
|
private let api = APIClient.shared
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
error = nil
|
|
do {
|
|
feeds = try await api.fetchFeeds()
|
|
} catch let e as APIError {
|
|
error = e.errorDescription
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
isLoading = false
|
|
}
|
|
|
|
func add(url: String, name: String) async -> Bool {
|
|
do {
|
|
let feed = try await api.addFeed(url: url, name: name)
|
|
feeds.append(feed)
|
|
return true
|
|
} catch let e as APIError {
|
|
error = e.errorDescription
|
|
return false
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
|
|
func delete(_ feed: Feed) async {
|
|
// Optimistic removal; restore on failure.
|
|
let snapshot = feeds
|
|
feeds.removeAll { $0.id == feed.id }
|
|
do {
|
|
try await api.deleteFeed(id: feed.id)
|
|
} catch {
|
|
feeds = snapshot
|
|
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
|
|
}
|
|
}
|
|
|
|
/// Apply a live `feed.health` WebSocket event.
|
|
func applyHealth(_ event: WSEvent) {
|
|
guard let id = event.feedId, let health = event.health,
|
|
let idx = feeds.firstIndex(where: { $0.id == id }) else { return }
|
|
let f = feeds[idx]
|
|
feeds[idx] = Feed(
|
|
id: f.id, name: f.name, url: f.url, health: health,
|
|
pollIntervalSeconds: f.pollIntervalSeconds,
|
|
failureCount: event.failureCount ?? f.failureCount,
|
|
lastFetchedAt: f.lastFetchedAt, articleCountToday: f.articleCountToday
|
|
)
|
|
}
|
|
}
|
|
|
|
struct FeedManagerView: View {
|
|
@EnvironmentObject var ws: WebSocketManager
|
|
@EnvironmentObject var settings: ServerSettings
|
|
@StateObject private var vm = FeedManagerViewModel()
|
|
|
|
@State private var query = ""
|
|
@State private var showAdd = false
|
|
@State private var showConnectivity = false
|
|
|
|
private var filtered: [Feed] {
|
|
guard !query.isEmpty else { return vm.feeds }
|
|
return vm.feeds.filter {
|
|
$0.name.localizedCaseInsensitiveContains(query) ||
|
|
$0.url.localizedCaseInsensitiveContains(query)
|
|
}
|
|
}
|
|
private var healthy: [Feed] { filtered.filter { $0.health == .active } }
|
|
private var attention: [Feed] { filtered.filter { $0.health != .active } }
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Palette.background.ignoresSafeArea()
|
|
VStack(spacing: 0) {
|
|
header
|
|
serverCard
|
|
searchBar
|
|
feedList
|
|
}
|
|
}
|
|
.presentationDragIndicator(.visible)
|
|
.sheet(isPresented: $showAdd) {
|
|
AddFeedSheet { url, name in
|
|
await vm.add(url: url, name: name)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showConnectivity) {
|
|
ConnectivityView()
|
|
}
|
|
.task { await vm.load() }
|
|
.onReceive(NotificationCenter.default.publisher(for: .feedHealthChanged)) { note in
|
|
if let event = note.object as? WSEvent { vm.applyHealth(event) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .center) {
|
|
JarvisWordmark(size: 26)
|
|
Spacer()
|
|
Button { showAdd = true } label: {
|
|
HStack(spacing: 5) {
|
|
Image(systemName: "plus").font(.system(size: 13, weight: .heavy))
|
|
Text("Add feed").font(.system(size: 14, weight: .bold))
|
|
}
|
|
.foregroundStyle(.black)
|
|
.padding(.horizontal, 14)
|
|
.frame(height: 36)
|
|
.background(Palette.orange)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 14)
|
|
}
|
|
|
|
// MARK: - Platform connection card
|
|
|
|
private var serverCard: some View {
|
|
Button { showConnectivity = true } label: {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("PLATFORM")
|
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.kerning(0.8)
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
Text(settings.host ?? "not configured")
|
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(Palette.orange)
|
|
}
|
|
Spacer()
|
|
HStack(spacing: 6) {
|
|
Circle().fill(ws.connectionState.dotColor).frame(width: 7, height: 7)
|
|
Text(ws.connectionState.label.uppercased())
|
|
.font(.system(size: 10, weight: .bold))
|
|
.kerning(0.6)
|
|
.foregroundStyle(ws.connectionState.isLive ? Color(hex: "33C25E") : Color(hex: "888888"))
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
}
|
|
.padding(14)
|
|
.background(Palette.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 12)
|
|
}
|
|
|
|
// MARK: - Search
|
|
|
|
private var searchBar: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
TextField("", text: $query, prompt: Text("Search feeds").foregroundColor(Palette.tertiaryText))
|
|
.font(.system(size: 15, weight: .regular))
|
|
.foregroundStyle(Palette.primaryText)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.frame(height: 44)
|
|
.background(Palette.surface2)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: - List
|
|
|
|
private var feedList: some View {
|
|
List {
|
|
if vm.isLoading && vm.feeds.isEmpty {
|
|
loadingRow
|
|
}
|
|
if !healthy.isEmpty {
|
|
section(title: "HEALTHY", feeds: healthy)
|
|
}
|
|
if !attention.isEmpty {
|
|
section(title: "NEEDS ATTENTION", feeds: attention)
|
|
}
|
|
if !vm.isLoading && filtered.isEmpty {
|
|
emptyRow
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Palette.background)
|
|
.refreshable { await vm.load() }
|
|
}
|
|
|
|
private func section(title: String, feeds: [Feed]) -> some View {
|
|
Section {
|
|
ForEach(feeds) { feed in
|
|
FeedRowView(feed: feed)
|
|
.listRowBackground(Palette.background)
|
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
|
.listRowSeparatorTint(Palette.hairline)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
Task { await vm.delete(feed) }
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text(title)
|
|
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
|
.kerning(0.8)
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 6, trailing: 16))
|
|
}
|
|
}
|
|
|
|
private var loadingRow: some View {
|
|
ProgressView().tint(Palette.orange)
|
|
.frame(maxWidth: .infinity)
|
|
.listRowBackground(Palette.background)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
|
|
private var emptyRow: some View {
|
|
Text(query.isEmpty ? (vm.error ?? "No feeds configured.") : "No feeds match “\(query)”.")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.listRowBackground(Palette.background)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
|
|
// MARK: - Feed row
|
|
|
|
struct FeedRowView: View {
|
|
let feed: Feed
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "dot.radiowaves.up.forward")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(Color(hex: "777777"))
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(feed.name)
|
|
.font(.system(size: 15, weight: .bold))
|
|
.foregroundStyle(Palette.primaryText)
|
|
.lineLimit(1)
|
|
Text(metaLine)
|
|
.font(.system(size: 11, weight: .regular, design: .monospaced))
|
|
.foregroundStyle(Palette.tertiaryText)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
healthDot
|
|
}
|
|
.frame(minHeight: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private var metaLine: String {
|
|
let interval = pollText(feed.pollIntervalSeconds)
|
|
let base = "Polls \(interval) · \(feed.articleCountToday) today"
|
|
if feed.health == .failing {
|
|
return base + " · ⚠ \(retryText)"
|
|
}
|
|
if feed.health == .dead {
|
|
return base + " · dead"
|
|
}
|
|
return base
|
|
}
|
|
|
|
private var retryText: String {
|
|
guard let last = feed.lastFetchedAt else { return "retrying" }
|
|
let next = last.addingTimeInterval(Double(feed.pollIntervalSeconds))
|
|
let secs = Int(next.timeIntervalSinceNow)
|
|
return secs > 0 ? "retry \(secs)s" : "retrying…"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var healthDot: some View {
|
|
// Live-updating countdown for failing feeds.
|
|
if feed.health == .failing {
|
|
TimelineView(.periodic(from: .now, by: 1)) { _ in
|
|
dot(color: Palette.healthFailing)
|
|
}
|
|
} else {
|
|
dot(color: feed.health == .active ? Palette.healthActive : Palette.healthDead)
|
|
}
|
|
}
|
|
|
|
private func dot(color: Color) -> some View {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 10, height: 10)
|
|
.overlay(Circle().stroke(color.opacity(0.9), lineWidth: 3).blur(radius: 2))
|
|
}
|
|
|
|
private func pollText(_ secs: Int) -> String {
|
|
if secs % 3600 == 0 { return "every \(secs / 3600) hr" }
|
|
if secs >= 60 { return "every \(secs / 60) min" }
|
|
return "every \(secs) s"
|
|
}
|
|
}
|