Files
ios-gitea-prs/GiteaPR/Inbox/InboxViewModel.swift
T
Hermes bae531f850
Build iOS App / Build .ipa and publish source (push) Successful in 31s
Add push-equivalent notifications via background polling; bump to 3.6
Sideloaded apps can't use APNs (no push entitlement without Apple signing),
so deliver notifications via an iOS BGAppRefreshTask that polls the monitored
repos every ~15 min and fires local notifications (no Apple push needed):
- new PRs that appear in the open set
- CI status changes on existing PRs

Persistence: NotificationState tracks announced PR ids + last-known CI status
so we only fire on actual changes, not every poll. AppDelegate registers the
background task and reschedules the next one. Info.plist gets UIBackgroundModes
fetch + the BGTask identifier. Settings gains a Notifications toggle; also
poll after a foreground inbox load so state seeds and changes are caught
promptly. Bump to 3.6.
2026-08-24 08:18:29 +00:00

110 lines
4.5 KiB
Swift

import Foundation
import SwiftUI
/// A PR row in the inbox. Deliberately lightweight: just the repo + PR, with
/// NO per-PR CI/review enrichment. Enriching every PR with runs+reviews was
/// the main cause of the slow landing screen (2 extra API calls per PR).
/// CI/review status is shown in the PR detail screen instead.
struct PRRow: Identifiable, Hashable, Codable {
let repo: Repository
let pr: PullRequest
var id: String { "\(repo.fullName)#\(pr.number)" }
var fullName: String { repo.fullName }
}
@MainActor
final class InboxViewModel: ObservableObject {
@Published var rows: [PRRow] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var displayedRows: [PRRow] = []
private let cache = InboxCache()
var isConfigured: Bool { SettingsStore.shared.isConfigured }
/// Load the inbox. Shows the cached list instantly, then refreshes from
/// the network in the background and updates when done.
func load() async {
guard isConfigured, let client = SettingsStore.shared.client() else {
errorMessage = "Configure your Gitea instance in Settings."
return
}
// 1. Render the cached list immediately (no network wait).
if displayedRows.isEmpty {
let cached = cache.load()
if !cached.isEmpty {
displayedRows = cached
}
}
// 2. Refresh from the network in the background.
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let repos = try await client.repos()
// If the user has configured monitored repos, only fetch PRs for
// those — this cuts the number of API calls dramatically (the
// inbox was fetching every accessible repo).
let monitored = SettingsStore.shared.monitoredRepos
let targets = monitored.isEmpty
? repos
: repos.filter { monitored.contains($0.fullName) }
LogStore.shared.log(.info, "Inbox: \(targets.count)/\(repos.count) repos")
// Fetch open PRs across the target repos concurrently. A single
// repo that fails (e.g. 404) must NOT wipe the whole inbox — catch
// per-repo errors, log them, and keep whatever succeeded.
var rows: [PRRow] = []
var failures: [String] = []
await withTaskGroup(of: Result<[PRRow], Error>.self) { group in
for repo in targets {
group.addTask {
do {
let prs = try await client.pullRequests(owner: repo.owner.login,
repo: repo.name)
return .success(prs.map { PRRow(repo: repo, pr: $0) })
} catch {
return .failure(error)
}
}
}
for await result in group {
switch result {
case .success(let batch):
rows.append(contentsOf: batch)
case .failure(let error):
failures.append(error.localizedDescription)
}
}
}
if !failures.isEmpty {
LogStore.shared.log(.warn, "Inbox: \(failures.count) repo(s) failed: \(failures.joined(separator: "; "))")
}
// Sort: most recently updated first.
rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) }
displayedRows = rows
cache.save(rows)
if rows.isEmpty && !failures.isEmpty {
errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen."
}
// When notifications are on, poll after a foreground load so state
// seeds and changes are caught promptly (not just on the ~15-min
// background task).
if SettingsStore.shared.notificationsEnabled {
Task { await NotificationService.shared.poll() }
}
} catch {
LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)")
errorMessage = error.localizedDescription
// Keep the cached list on a network failure rather than blanking it.
if displayedRows.isEmpty {
displayedRows = cache.load()
}
}
}
}