Files
Outpost/Outpost/Root/RootView.swift
T
Puranjay Savar Mattas 284ad07251 fix(offline): false "New changes available" banner, sync stuck on toggle-off
Home's checkForRemoteChanges compared fresh pinned docs against what
was displayed, but the pinned fetch (listPins isn't a cached endpoint)
silently collapsed any failure to []  — every poll while offline
compared "[]" against the real non-empty pinned list, which always
looked like a change and popped the banner every ~45s. Split fetchPinned
into a throwing variant checkForRemoteChanges can bail on (matching the
already-correct pattern in CollectionsViewModel/DocumentsViewModel,
which don't have this bug), keeping the non-throwing version for the
initial load where collapsing to [] is the right behavior.

RootView's auto-flush was keyed only to session.networkMonitor.isOnline
— turning the manual Offline Mode toggle back off while the real
network had been up the whole time never changes that value, so queued
operations sat stuck until the next real network blip. Keyed the flush
(and the Full Local Sync loop's online check) to a combined
isEffectivelyOnline instead, so either signal clearing resumes sync
immediately.
2026-08-15 01:40:07 +01:00

90 lines
3.7 KiB
Swift

import SwiftUI
import OutlineKit
struct RootView: View {
@Environment(SessionStore.self) private var session
@State private var welcomeName: String?
@State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" — the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ZStack {
if session.isSignedIn {
ContentView()
} else {
AuthView(onSigningIn: startWelcomeTransition)
}
if let welcomeName {
WelcomeSplashView(name: welcomeName)
.transition(.opacity)
.zIndex(1)
}
}
.environment(starStore)
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
.task {
await session.refreshTeamInfoIfNeeded()
}
.task(id: session.isSignedIn) {
if session.isSignedIn, let apiClient = session.apiClient {
await starStore.load(apiClient: apiClient)
} else {
starStore.reset()
}
}
// Replay whatever queued up while offline the moment either signal
// clears — a real reconnect, or the user turning Offline Mode back
// off — no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
_ = await cachingClient.flushPendingOperations()
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
}
// Fully automatic — this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch" —
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key — then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
}
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
welcomeName = result.user.name
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
Task {
try? await Task.sleep(for: .seconds(1.1))
withAnimation(.easeInOut(duration: 0.45)) {
welcomeName = nil
}
}
}
}