From ec36f722bf802b576afe3ec3800dbc66771684cc Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 26 Jul 2026 14:05:16 +0200 Subject: [PATCH] Keep recording-error alerts from ending the open DM Voice recording happens inside the people/DM sheet, but its error alert was still presented unconditionally from the root view. A mic-permission failure therefore force-dismissed the sheet and ended the conversation through exactly the reconciliation path this branch fixes for the Bluetooth alert. Treat it the same way: the sheet-dismiss guard now also ignores reconciliation while the voice alert is up, the root copy of the alert defers to any other root modal (people sheet included), and the sheet presents its own copy gated on the sheet's modal state. The voice alert keeps priority over the Bluetooth alert on both levels via the existing isVoiceAlertPresented flag, now mirrored into the people-sheet state. Co-Authored-By: Claude Opus 4.8 --- bitchat/Views/ContentSheetViews.swift | 51 ++++++++++++++++++++++++- bitchat/Views/ContentView.swift | 55 ++++++++++++++++++++++----- bitchatTests/ViewSmokeTests.swift | 14 +++++++ 3 files changed, 109 insertions(+), 11 deletions(-) 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() {