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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-26 14:05:16 +02:00
co-authored by Claude Opus 4.8
parent be8dd52bda
commit 7251bc88fa
3 changed files with 109 additions and 11 deletions
+49 -2
View File
@@ -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<Bool> {
@@ -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<Bool> {
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
+46 -9
View File
@@ -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<Bool> {
@@ -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<Bool> {
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") {
+14
View File
@@ -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() {