Build iOS App / Build .ipa and publish source (push) Successful in 31s
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.
110 lines
4.5 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|