diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 859b79f3..a4092b43 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -10,12 +10,14 @@ 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 } } @@ -51,7 +53,9 @@ struct ContentPeopleSheetView: View { @Binding var showMacImagePicker: Bool #endif - private var hasModalPresentation: Bool { + private func modalPresentationState( + includingVoiceAlert: Bool + ) -> ContentPeopleSheetModalPresentationState { #if os(iOS) let isMediaPickerPresented = showImagePicker #else @@ -63,8 +67,19 @@ struct ContentPeopleSheetView: View { isVerificationSheetPresented: showVerifySheet, legacyPrivateMediaConsentRequest: conversationUIModel.legacyPrivateMediaConsentRequest, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, isMediaPickerPresented: isMediaPickerPresented - ).hasPresentation + ) + } + + 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 { @@ -85,6 +100,28 @@ struct ContentPeopleSheetView: View { ) } + /// 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 { @@ -233,6 +270,16 @@ 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 diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 654eb1cd..be2bdeec 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -140,7 +140,9 @@ struct ContentView: View { showSidebar || selectedPrivatePeerID != nil } - private var hasRootModalPresentation: Bool { + private func rootModalPresentationState( + includingVoiceAlert: Bool + ) -> ContentRootModalPresentationState { #if os(iOS) let isMediaPickerPresented = showImagePicker #else @@ -152,9 +154,19 @@ struct ContentView: View { isPeopleSheetPresented: isPeopleSheetPresented, isImagePreviewPresented: imagePreviewURL != nil, isVerificationSheetPresented: showVerifySheet, - isVoiceAlertPresented: voiceRecordingVM.showAlert, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, isMediaPickerPresented: isMediaPickerPresented - ).hasPresentation + ) + } + + 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 { @@ -175,6 +187,29 @@ struct ContentView: View { ) } + /// 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 { @@ -220,12 +255,14 @@ struct ContentView: View { set: { isPresented in if !isPresented { showSidebar = false - // Scene/background and Bluetooth-alert presentation - // reconciliation are not user requests to leave the - // conversation. Keep the selected DM so the sheet - // remains live when the app returns from Settings. + // 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 { + !appChromeModel.showBluetoothAlert, + !voiceRecordingVM.showAlert { privateConversationModel.endConversation() } } @@ -328,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") { diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 2d9f34b5..ec7b3b62 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -579,6 +579,20 @@ struct ViewSmokeTests { ) } + @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() {