From 4ad82657bc522a5e991a8b27c6449e1dcc7109d3 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:38:51 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(ios):=20propagation=20sync=20sheet=20?= =?UTF-8?q?=E2=80=94=20manual-only=20presentation=20+=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The propagation-node sync status sheet popped up automatically on every sync, including background, periodic, and on-foreground auto-syncs — not just user-initiated ones. It also rendered as an opaque content-sized band floating inside the system's default glass sheet chrome. - ChatsView: stop auto-presenting via `.onChange(of: syncState.isSyncing)`. The sheet is now shown only when the user taps a refresh button (toolbar and empty-state). Background / periodic / on-foreground / pull-to-refresh syncs update `syncState` silently. - SyncStatusBottomSheet: color the whole sheet with `.presentationBackground` instead of `.background(...)` on the content (the source of the glass + opaque-band artifact). Wrap content in a scroll-safe container so large Dynamic Type can't clip the progress bar. - PropagationNodeManager.syncNow: reset transfer state up front so the sheet opens on a clean "Connecting" state rather than flashing the prior run's result. - Present the sheet through a small container that reads `syncState` in a real view body, so SwiftUI Observation reliably drives live progress + the terminal result. Co-Authored-By: Claude Opus 4.8 --- .../Services/PropagationNodeManager.swift | 6 ++ .../ColumbaApp/Views/Chats/ChatsView.swift | 39 ++++++++--- .../Components/SyncStatusBottomSheet.swift | 64 +++++++++++-------- 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index 42a84a2..aec5a07 100644 --- a/Sources/ColumbaApp/Services/PropagationNodeManager.swift +++ b/Sources/ColumbaApp/Services/PropagationNodeManager.swift @@ -315,6 +315,12 @@ public final class PropagationNodeManager { /// /// If no propagation node is selected yet, auto-selects the best available node first. public func syncNow() async { + // Reset transfer state up front so any observer (the in-app status sheet) shows + // THIS sync's progress from a clean "connecting" slate, not the previous run's + // stale "Download complete / N new messages". Early-return guards below overwrite + // this with the appropriate terminal state (e.g. .noPath). + syncState = PropagationTransferState(state: .linking) + // Model B: the LXMF router lives in the NE — the app can't sync in-process. // Ensure a PN is selected + its config is in the seam, then fire the sync-now // Darwin trigger. Real progress arrives back via the sync-state channel diff --git a/Sources/ColumbaApp/Views/Chats/ChatsView.swift b/Sources/ColumbaApp/Views/Chats/ChatsView.swift index e6d59e9..15f8f52 100644 --- a/Sources/ColumbaApp/Views/Chats/ChatsView.swift +++ b/Sources/ColumbaApp/Views/Chats/ChatsView.swift @@ -45,7 +45,8 @@ struct ChatsView: View { /// Conversation pending deletion (confirmation alert). @State private var deletingConversation: Conversation? - /// Controls the propagation-sync status sheet (auto-shown while a sync is active). + /// Controls the propagation-sync status sheet. Presented only for user-initiated + /// syncs (tapping the refresh button); background / periodic syncs run silently. @State private var isSyncSheetPresented: Bool = false // MARK: - Theme Colors @@ -97,6 +98,8 @@ struct ChatsView: View { // Refresh button — syncs from propagation node then reloads DB Button { guard viewModel?.isRefreshing != true else { return } + // User explicitly asked to sync → surface the status sheet. + isSyncSheetPresented = true Task { viewModel?.isRefreshing = true await appServices.propagationManager?.syncNow() @@ -166,14 +169,16 @@ struct ChatsView: View { } message: { Text("This will permanently delete the conversation and all its messages.") } - // Auto-show the sync status sheet while a propagation sync is active (manual - // Sync Now / pull-to-refresh, or — under Model B — an NE-driven periodic sync). - // The sheet stays up through the terminal phase so the user sees the result. - .onChange(of: appServices.propagationManager?.syncState.isSyncing ?? false) { _, active in - if active { isSyncSheetPresented = true } - } + // Show the sync status sheet only for user-initiated syncs — the refresh button + // sets `isSyncSheetPresented`. Background / periodic syncs (including Model B's + // NE-driven periodic sync) update `syncState` silently without popping the sheet. + // Once presented, the sheet observes `syncState` for live progress and stays up + // through the terminal phase so the user sees the result, then dismisses manually. .sheet(isPresented: $isSyncSheetPresented) { - SyncStatusBottomSheet(state: appServices.propagationManager?.syncState ?? PropagationTransferState()) + // Container reads `syncState` in its own view body (not just inside this + // closure) so SwiftUI Observation reliably re-renders the sheet on every + // transfer-state change — live progress and the terminal result. + SyncStatusSheetContainer(manager: appServices.propagationManager) } #if os(iOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in @@ -377,6 +382,8 @@ struct ChatsView: View { // Refresh button — syncs from propagation node then reloads DB Button { + // User explicitly asked to sync → surface the status sheet. + isSyncSheetPresented = true Task { await appServices.propagationManager?.syncNow() await viewModel?.refreshConversations() @@ -411,6 +418,22 @@ struct ChatsView: View { } } +// MARK: - Sync Status Sheet Container + +/// Bridges the app's `@Observable` `PropagationNodeManager` to the pure, value-typed +/// `SyncStatusBottomSheet`. Reading `manager.syncState` inside this view's `body` +/// (rather than only inside the parent's `.sheet` content closure) guarantees SwiftUI +/// Observation re-renders the sheet whenever the transfer state changes, so live +/// progress and the terminal result always appear. +@available(iOS 17.0, macOS 14.0, *) +private struct SyncStatusSheetContainer: View { + let manager: PropagationNodeManager? + + var body: some View { + SyncStatusBottomSheet(state: manager?.syncState ?? PropagationTransferState()) + } +} + // MARK: - Button Style /// Custom button style for conversation rows with press animation. diff --git a/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift index 35af09f..46f6031 100644 --- a/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift +++ b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift @@ -20,40 +20,50 @@ struct SyncStatusBottomSheet: View { let state: PropagationTransferState var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header - HStack(spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(Theme.accentColor) - Text("Propagation Node Sync") - .font(.title3.weight(.bold)) - .foregroundColor(Theme.textPrimary) - } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(Theme.accentColor) + Text("Propagation Node Sync") + .font(.title3.weight(.bold)) + .foregroundColor(Theme.textPrimary) + } - Spacer().frame(height: 24) + Spacer().frame(height: 24) - // Status row - statusRow + // Status row + statusRow - // Progress bar (only while actively receiving with known progress) - if showProgressBar { - Spacer().frame(height: 16) - ProgressView(value: min(max(state.progress, 0), 1)) - .tint(Theme.accentColor) - Spacer().frame(height: 8) - Text("\(Int((min(max(state.progress, 0), 1)) * 100))%") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) + // Progress bar (only while actively receiving with known progress) + if showProgressBar { + Spacer().frame(height: 16) + ProgressView(value: min(max(state.progress, 0), 1)) + .tint(Theme.accentColor) + Spacer().frame(height: 8) + Text("\(Int((min(max(state.progress, 0), 1)) * 100))%") + .font(.subheadline) + .foregroundColor(Theme.textSecondary) + } } + .padding(.horizontal, 24) + .padding(.top, 28) + .padding(.bottom, 36) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 24) - .padding(.top, 28) - .padding(.bottom, 36) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.backgroundPrimary.ignoresSafeArea()) + // Scrolls only when content exceeds the detent (e.g. at large Dynamic Type); + // otherwise behaves like a static view, so the progress bar and percentage are + // never clipped at the bottom of the fixed-height sheet. + .scrollBounceBehavior(.basedOnSize) .presentationDetents([.height(220)]) .presentationDragIndicator(.visible) + // Color the entire sheet, not just the content. Using `.background(...)` on the + // content paints an opaque band sized to the VStack inside the system's default + // glass sheet chrome; `.presentationBackground` is the SwiftUI-correct way to set + // a sheet's backing surface (iOS 16.4+). + .presentationBackground(Theme.backgroundPrimary) } // MARK: - Status row From 74a1411936c0ebf21606e243a7c64a8dc3b11200 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:01:11 -0400 Subject: [PATCH 2/2] fix(ios): gate sync-state reset to user-initiated syncs (greploop iteration 1) Addresses Greptile P2: `syncNow()` reset `syncState` to `.linking` for every caller, so a background / periodic / on-foreground sync could clobber a status sheet the user had left open on a prior result (e.g. snap "3 new messages" back to "Connecting"). The reset only matters for syncs that present the sheet. Add `syncNow(userInitiated:)` (default false); reset the displayed transfer state only when true. Pass true from the user-initiated paths (Chats refresh button, pull-to-refresh, empty-state refresh, Settings Sync Now, Messaging Sync Messages); leave background callers (on-foreground auto-sync, BGAppRefresh, --auto-sync debug, periodic task, test hook) on the default. Co-Authored-By: Claude Opus 4.8 --- .../Services/PropagationNodeManager.swift | 20 +++++++++++++------ .../ViewModels/SettingsViewModel.swift | 2 +- .../ColumbaApp/Views/Chats/ChatsView.swift | 6 +++--- .../Views/Messaging/MessagingView.swift | 2 +- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index aec5a07..51fbd53 100644 --- a/Sources/ColumbaApp/Services/PropagationNodeManager.swift +++ b/Sources/ColumbaApp/Services/PropagationNodeManager.swift @@ -314,12 +314,20 @@ public final class PropagationNodeManager { /// Trigger an immediate sync from the propagation node. /// /// If no propagation node is selected yet, auto-selects the best available node first. - public func syncNow() async { - // Reset transfer state up front so any observer (the in-app status sheet) shows - // THIS sync's progress from a clean "connecting" slate, not the previous run's - // stale "Download complete / N new messages". Early-return guards below overwrite - // this with the appropriate terminal state (e.g. .noPath). - syncState = PropagationTransferState(state: .linking) + /// - Parameter userInitiated: `true` when the user explicitly triggered the sync (a + /// refresh button, pull-to-refresh, or a Sync Now action) — i.e. the cases that may + /// present the status sheet. Only these reset the displayed transfer state up front + /// (see below). Background, periodic, and on-foreground auto-syncs pass `false`. + public func syncNow(userInitiated: Bool = false) async { + // For user-initiated syncs only, reset transfer state up front so a freshly-opened + // status sheet shows THIS sync's progress from a clean "connecting" slate, not the + // previous run's stale "Download complete / N new messages". Background / periodic + // / on-foreground syncs skip this so they never clobber a status sheet the user may + // have left open on a prior result. Early-return guards below overwrite this with + // the appropriate terminal state (e.g. .noPath). + if userInitiated { + syncState = PropagationTransferState(state: .linking) + } // Model B: the LXMF router lives in the NE — the app can't sync in-process. // Ensure a PN is selected + its config is in the seam, then fire the sync-now diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index 404c7ca..dc3e37b 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -719,7 +719,7 @@ public final class SettingsViewModel { isSyncing = true syncError = nil - await propManager.syncNow() + await propManager.syncNow(userInitiated: true) syncProgress = propManager.syncState.progress lastSyncTime = propManager.lastSyncTime diff --git a/Sources/ColumbaApp/Views/Chats/ChatsView.swift b/Sources/ColumbaApp/Views/Chats/ChatsView.swift index 15f8f52..5d021fe 100644 --- a/Sources/ColumbaApp/Views/Chats/ChatsView.swift +++ b/Sources/ColumbaApp/Views/Chats/ChatsView.swift @@ -102,7 +102,7 @@ struct ChatsView: View { isSyncSheetPresented = true Task { viewModel?.isRefreshing = true - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.refreshConversations() viewModel?.isRefreshing = false } @@ -337,7 +337,7 @@ struct ChatsView: View { // Sync with timeout so pull-to-refresh doesn't hang await withTaskGroup(of: Void.self) { group in group.addTask { - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) } group.addTask { try? await Task.sleep(for: .seconds(15)) @@ -385,7 +385,7 @@ struct ChatsView: View { // User explicitly asked to sync → surface the status sheet. isSyncSheetPresented = true Task { - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.refreshConversations() } } label: { diff --git a/Sources/ColumbaApp/Views/Messaging/MessagingView.swift b/Sources/ColumbaApp/Views/Messaging/MessagingView.swift index 0561a3e..4b499b5 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessagingView.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessagingView.swift @@ -533,7 +533,7 @@ struct MessagingView: View { Task { isSyncing = true defer { isSyncing = false } - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.loadMessages() } } label: {