diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 6eaa12ad..a4092b43 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -6,11 +6,28 @@ import UIKit import AppKit #endif +struct ContentPeopleSheetModalPresentationState { + var isImagePreviewPresented = false + var isVerificationSheetPresented = false + var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil + var isVoiceAlertPresented = false + var isMediaPickerPresented = false + + var hasPresentation: Bool { + isImagePreviewPresented + || isVerificationSheetPresented + || legacyPrivateMediaConsentRequest != nil + || isVoiceAlertPresented + || isMediaPickerPresented + } +} + struct ContentPeopleSheetView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel + @Environment(\.scenePhase) private var scenePhase @Binding var showSidebar: Bool @Binding var messageText: String @@ -23,6 +40,7 @@ struct ContentPeopleSheetView: View { var isTextFieldFocused: FocusState.Binding @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @Binding var autocompleteDebounceTimer: Timer? + @State private var showVerifySheet = false @ThemedPalette private var palette let headerHeight: CGFloat @@ -35,6 +53,75 @@ struct ContentPeopleSheetView: View { @Binding var showMacImagePicker: Bool #endif + private func modalPresentationState( + includingVoiceAlert: Bool + ) -> ContentPeopleSheetModalPresentationState { + #if os(iOS) + let isMediaPickerPresented = showImagePicker + #else + let isMediaPickerPresented = showMacImagePicker + #endif + + return ContentPeopleSheetModalPresentationState( + isImagePreviewPresented: imagePreviewURL != nil, + isVerificationSheetPresented: showVerifySheet, + legacyPrivateMediaConsentRequest: + conversationUIModel.legacyPrivateMediaConsentRequest, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, + isMediaPickerPresented: isMediaPickerPresented + ) + } + + private var hasModalPresentation: Bool { + modalPresentationState(includingVoiceAlert: true).hasPresentation + } + + /// The voice alert cannot defer to itself: its own binding must keep + /// reporting `true` while it is the presented modal. + private var hasModalPresentationBesidesVoiceAlert: Bool { + modalPresentationState(includingVoiceAlert: false).hasPresentation + } + + private var bluetoothAlertBinding: Binding { + Binding( + get: { + scenePhase == .active + && appChromeModel.showBluetoothAlert + && !hasModalPresentation + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasModalPresentation else { + return + } + appChromeModel.showBluetoothAlert = false + } + ) + } + + /// Voice recording happens inside this sheet, so its error alert must + /// present from here as well: the root copy defers whenever this sheet + /// is up, exactly like the Bluetooth alert above. Presenting from the + /// root instead would force-dismiss the sheet and end the conversation. + private var voiceAlertBinding: Binding { + Binding( + get: { + scenePhase == .active + && voiceRecordingVM.showAlert + && !hasModalPresentationBesidesVoiceAlert + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasModalPresentationBesidesVoiceAlert else { + return + } + voiceRecordingVM.showAlert = false + } + ) + } + var body: some View { let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest NavigationStack { @@ -78,7 +165,8 @@ struct ContentPeopleSheetView: View { #endif } else { ContentPeopleListView( - showSidebar: $showSidebar + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet ) } } @@ -182,6 +270,27 @@ struct ContentPeopleSheetView: View { } } #endif + .alert("Recording Error", isPresented: voiceAlertBinding, actions: { + Button("common.ok", role: .cancel) {} + if voiceRecordingVM.state == .permissionDenied { + Button("location_channels.action.open_settings") { + SystemSettings.microphone.open() + } + } + }, message: { + Text(voiceRecordingVM.state.alertMessage) + }) + .alert( + "content.alert.bluetooth_required.title", + isPresented: bluetoothAlertBinding + ) { + Button("content.alert.bluetooth_required.settings") { + SystemSettings.bluetooth.open() + } + Button("common.ok", role: .cancel) {} + } message: { + Text(appChromeModel.bluetoothAlertMessage) + } } } @@ -196,8 +305,7 @@ private struct ContentPeopleListView: View { @ThemedPalette private var palette @Binding var showSidebar: Bool - - @State private var showVerifySheet = false + @Binding var showVerifySheet: Bool var body: some View { VStack(spacing: 0) { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 2d62f5a2..be2bdeec 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -14,6 +14,61 @@ import AppKit #endif import BitFoundation +struct ContentRootModalPresentationState { + var isPeopleSheetPresented = false + var isAppInfoPresented = false + var isFingerprintPresented = false + var isLocationChannelsSheetPresented = false + var isNoticesSheetPresented = false + var isImagePreviewPresented = false + var isVerificationSheetPresented = false + var isVoiceAlertPresented = false + var isScreenshotPrivacyAlertPresented = false + var isMediaPickerPresented = false + + var hasPresentation: Bool { + isPeopleSheetPresented + || isAppInfoPresented + || isFingerprintPresented + || isLocationChannelsSheetPresented + || isNoticesSheetPresented + || isImagePreviewPresented + || isVerificationSheetPresented + || isVoiceAlertPresented + || isScreenshotPrivacyAlertPresented + || isMediaPickerPresented + } +} + +extension ContentRootModalPresentationState { + @MainActor + init( + appChromeModel: AppChromeModel, + isPeopleSheetPresented: Bool = false, + isImagePreviewPresented: Bool = false, + isVerificationSheetPresented: Bool = false, + isVoiceAlertPresented: Bool = false, + isMediaPickerPresented: Bool = false + ) { + self.init( + isPeopleSheetPresented: isPeopleSheetPresented, + isAppInfoPresented: appChromeModel.isAppInfoPresented, + isFingerprintPresented: + appChromeModel.showingFingerprintFor != nil, + isLocationChannelsSheetPresented: + appChromeModel.isLocationChannelsSheetPresented, + isNoticesSheetPresented: + appChromeModel.isNoticesSheetPresented, + isImagePreviewPresented: isImagePreviewPresented, + isVerificationSheetPresented: isVerificationSheetPresented, + isVoiceAlertPresented: isVoiceAlertPresented, + isScreenshotPrivacyAlertPresented: + appChromeModel.showScreenshotPrivacyWarning, + isMediaPickerPresented: isMediaPickerPresented + ) + } +} + /// On macOS 14+, disables the default system focus ring on TextFields. /// On earlier macOS versions and on iOS this is a no-op. struct FocusEffectDisabledModifier: ViewModifier { @@ -43,6 +98,7 @@ struct ContentView: View { @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.appTheme) private var appTheme + @Environment(\.scenePhase) private var scenePhase @State private var showSidebar = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @@ -80,6 +136,80 @@ struct ContentView: View { private var usesGlassLayout: Bool { appTheme.usesGlassChrome } + private var isPeopleSheetPresented: Bool { + showSidebar || selectedPrivatePeerID != nil + } + + private func rootModalPresentationState( + includingVoiceAlert: Bool + ) -> ContentRootModalPresentationState { + #if os(iOS) + let isMediaPickerPresented = showImagePicker + #else + let isMediaPickerPresented = showMacImagePicker + #endif + + return ContentRootModalPresentationState( + appChromeModel: appChromeModel, + isPeopleSheetPresented: isPeopleSheetPresented, + isImagePreviewPresented: imagePreviewURL != nil, + isVerificationSheetPresented: showVerifySheet, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, + isMediaPickerPresented: isMediaPickerPresented + ) + } + + private var hasRootModalPresentation: Bool { + rootModalPresentationState(includingVoiceAlert: true).hasPresentation + } + + /// The voice alert cannot defer to itself: its own binding must keep + /// reporting `true` while it is the presented modal. + private var hasRootModalPresentationBesidesVoiceAlert: Bool { + rootModalPresentationState(includingVoiceAlert: false).hasPresentation + } + + private var rootBluetoothAlertBinding: Binding { + Binding( + get: { + scenePhase == .active + && appChromeModel.showBluetoothAlert + && !hasRootModalPresentation + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasRootModalPresentation else { + return + } + appChromeModel.showBluetoothAlert = false + } + ) + } + + /// Voice recording errors can surface while the people/DM sheet is up + /// (recording happens inside the sheet). Presenting the root alert then + /// would force-dismiss the sheet, so the root copy defers to any other + /// root modal; the sheet presents its own copy. Mirrors the Bluetooth + /// alert treatment above. + private var rootVoiceAlertBinding: Binding { + Binding( + get: { + scenePhase == .active + && voiceRecordingVM.showAlert + && !hasRootModalPresentationBesidesVoiceAlert + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasRootModalPresentationBesidesVoiceAlert else { + return + } + voiceRecordingVM.showAlert = false + } + ) + } + var body: some View { mainContent .onAppear { @@ -121,11 +251,20 @@ struct ContentView: View { } .sheet( isPresented: Binding( - get: { showSidebar || selectedPrivatePeerID != nil }, + get: { isPeopleSheetPresented }, set: { isPresented in if !isPresented { showSidebar = false - privateConversationModel.endConversation() + // Scene/background and alert-presentation + // reconciliation (Bluetooth-off, recording errors) + // are not user requests to leave the conversation. + // Keep the selected DM so the sheet remains live + // when the app returns from Settings. + if scenePhase == .active, + !appChromeModel.showBluetoothAlert, + !voiceRecordingVM.showAlert { + privateConversationModel.endConversation() + } } } ) @@ -226,7 +365,7 @@ struct ContentView: View { ImagePreviewView(url: url) } } - .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { + .alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: { Button("common.ok", role: .cancel) {} if voiceRecordingVM.state == .permissionDenied { Button("location_channels.action.open_settings") { @@ -236,7 +375,7 @@ struct ContentView: View { }, message: { Text(voiceRecordingVM.state.alertMessage) }) - .alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) { + .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { SystemSettings.bluetooth.open() } diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 91753ca5..ec7b3b62 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -554,6 +554,103 @@ struct ViewSmokeTests { #expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID) } + @Test("Root Bluetooth alert waits for location and notices sheets") + func rootBluetoothAlertGuard_includesHeaderSheets() { + #expect(!ContentRootModalPresentationState().hasPresentation) + #expect( + ContentRootModalPresentationState( + isLocationChannelsSheetPresented: true + ).hasPresentation + ) + #expect( + ContentRootModalPresentationState( + isNoticesSheetPresented: true + ).hasPresentation + ) + } + + @Test("People-sheet Bluetooth alert waits for local verification sheet") + func peopleSheetBluetoothAlertGuard_includesVerificationSheet() { + #expect(!ContentPeopleSheetModalPresentationState().hasPresentation) + #expect( + ContentPeopleSheetModalPresentationState( + isVerificationSheetPresented: true + ).hasPresentation + ) + } + + @Test("Bluetooth alerts wait for the voice recording error alert") + func bluetoothAlertGuards_includeVoiceAlert() { + #expect( + ContentRootModalPresentationState( + isVoiceAlertPresented: true + ).hasPresentation + ) + #expect( + ContentPeopleSheetModalPresentationState( + isVoiceAlertPresented: true + ).hasPresentation + ) + } + + @Test("Root Bluetooth alert waits for screenshot privacy alert") + @MainActor + func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() { + let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) + + #expect( + !ContentRootModalPresentationState( + appChromeModel: featureModels.appChromeModel + ).hasPresentation + ) + + featureModels.appChromeModel.showScreenshotPrivacyWarning = true + + #expect( + ContentRootModalPresentationState( + appChromeModel: featureModels.appChromeModel + ).hasPresentation + ) + } + + @Test("People-sheet Bluetooth alert waits for legacy media consent") + @MainActor + func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async { + let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) + + #expect( + !ContentPeopleSheetModalPresentationState( + legacyPrivateMediaConsentRequest: + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest + ).hasPresentation + ) + + viewModel.enqueueLegacyPrivateMediaConsent( + for: PeerID(str: "5152535455565758"), + transferId: "legacy-consent-transfer", + messageID: "legacy-consent-message" + ) { _ in } + defer { viewModel.cancelAllLegacyPrivateMediaConsents() } + + let consentPropagated = await TestHelpers.waitUntil { + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest != nil + } + #expect( + consentPropagated + ) + #expect( + ContentPeopleSheetModalPresentationState( + legacyPrivateMediaConsentRequest: + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest + ).hasPresentation + ) + } + @Test func geohashAndTextMessageViews_renderCoreBranches() { let (viewModel, _, _) = makeSmokeViewModel()