diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index d1888ce..56543ab 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -57,25 +57,33 @@ final class HomeViewModel { /// Fetches fresh pinned docs and the current tab's documents to compare /// against what's displayed, without replacing either. Bails silently /// on a fetch failure rather than treating it as "changed" — a - /// transient network hiccup shouldn't pop the refresh banner. + /// transient network hiccup (or, offline, `listPins` failing outright — + /// it isn't one of the cached endpoints) shouldn't pop the refresh + /// banner. This needs the *throwing* pinned-fetch specifically: the + /// plain `fetchPinned()` used elsewhere collapses any failure to `[]`, + /// which used to read here as "pins changed" against whatever was + /// already displayed and falsely popped the banner on every offline + /// poll. func checkForRemoteChanges(tab: HomeTab) async { - async let freshPinned = fetchPinned() + async let freshPinnedTask = fetchPinnedThrowing() guard let freshTab = try? await fetch(tab: tab) else { return } - let pinned = await freshPinned + guard let pinned = try? await freshPinnedTask else { return } if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { hasRemoteChanges = true } } + private func fetchPinned() async -> [OutlineDocument] { + (try? await fetchPinnedThrowing()) ?? [] + } + /// `pins.list` only returns pin records, not the documents themselves — /// fetches each pinned document individually. Pins are a small curated /// set (unlike a full collection tree), so the N+1 here is acceptable /// where it wouldn't be in the sidebar. - private func fetchPinned() async -> [OutlineDocument] { - guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { - return [] - } + private func fetchPinnedThrowing() async throws -> [OutlineDocument] { + let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) var documents: [OutlineDocument] = [] for pin in pins { if let document = try? await apiClient.documentInfo(id: pin.documentId) { diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 4df0d61..598475c 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -6,6 +6,18 @@ struct RootView: View { @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 { @@ -33,13 +45,13 @@ struct RootView: View { starStore.reset() } } - // Replay whatever queued up while offline the moment the network's - // back — no need to wait for the user to open Settings and hit Retry. - // Also catches Full Local Sync back up immediately on reconnect, - // rather than leaving it to wait out the rest of the periodic loop - // below. - .task(id: session.networkMonitor.isOnline) { - guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } + // 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() @@ -55,7 +67,7 @@ struct RootView: View { .task(id: isFullLocalSyncEnabled) { guard isFullLocalSyncEnabled else { return } while !Task.isCancelled { - if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { + if isEffectivelyOnline, let cachingClient = session.cachingClient { _ = await cachingClient.performFullSync() } try? await Task.sleep(for: .seconds(1200))