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 isImagePreviewPresented = false
var isVerificationSheetPresented = false var isVerificationSheetPresented = false
var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil
var isVoiceAlertPresented = false
var isMediaPickerPresented = false var isMediaPickerPresented = false
var hasPresentation: Bool { var hasPresentation: Bool {
isImagePreviewPresented isImagePreviewPresented
|| isVerificationSheetPresented || isVerificationSheetPresented
|| legacyPrivateMediaConsentRequest != nil || legacyPrivateMediaConsentRequest != nil
|| isVoiceAlertPresented
|| isMediaPickerPresented || isMediaPickerPresented
} }
} }
@@ -51,7 +53,9 @@ struct ContentPeopleSheetView: View {
@Binding var showMacImagePicker: Bool @Binding var showMacImagePicker: Bool
#endif #endif
private var hasModalPresentation: Bool { private func modalPresentationState(
includingVoiceAlert: Bool
) -> ContentPeopleSheetModalPresentationState {
#if os(iOS) #if os(iOS)
let isMediaPickerPresented = showImagePicker let isMediaPickerPresented = showImagePicker
#else #else
@@ -63,8 +67,19 @@ struct ContentPeopleSheetView: View {
isVerificationSheetPresented: showVerifySheet, isVerificationSheetPresented: showVerifySheet,
legacyPrivateMediaConsentRequest: legacyPrivateMediaConsentRequest:
conversationUIModel.legacyPrivateMediaConsentRequest, conversationUIModel.legacyPrivateMediaConsentRequest,
isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert,
isMediaPickerPresented: isMediaPickerPresented 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> { 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 { var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack { NavigationStack {
@@ -233,6 +270,16 @@ struct ContentPeopleSheetView: View {
} }
} }
#endif #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( .alert(
"content.alert.bluetooth_required.title", "content.alert.bluetooth_required.title",
isPresented: bluetoothAlertBinding isPresented: bluetoothAlertBinding
+46 -9
View File
@@ -140,7 +140,9 @@ struct ContentView: View {
showSidebar || selectedPrivatePeerID != nil showSidebar || selectedPrivatePeerID != nil
} }
private var hasRootModalPresentation: Bool { private func rootModalPresentationState(
includingVoiceAlert: Bool
) -> ContentRootModalPresentationState {
#if os(iOS) #if os(iOS)
let isMediaPickerPresented = showImagePicker let isMediaPickerPresented = showImagePicker
#else #else
@@ -152,9 +154,19 @@ struct ContentView: View {
isPeopleSheetPresented: isPeopleSheetPresented, isPeopleSheetPresented: isPeopleSheetPresented,
isImagePreviewPresented: imagePreviewURL != nil, isImagePreviewPresented: imagePreviewURL != nil,
isVerificationSheetPresented: showVerifySheet, isVerificationSheetPresented: showVerifySheet,
isVoiceAlertPresented: voiceRecordingVM.showAlert, isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert,
isMediaPickerPresented: isMediaPickerPresented 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> { 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 { var body: some View {
mainContent mainContent
.onAppear { .onAppear {
@@ -220,12 +255,14 @@ struct ContentView: View {
set: { isPresented in set: { isPresented in
if !isPresented { if !isPresented {
showSidebar = false showSidebar = false
// Scene/background and Bluetooth-alert presentation // Scene/background and alert-presentation
// reconciliation are not user requests to leave the // reconciliation (Bluetooth-off, recording errors)
// conversation. Keep the selected DM so the sheet // are not user requests to leave the conversation.
// remains live when the app returns from Settings. // Keep the selected DM so the sheet remains live
// when the app returns from Settings.
if scenePhase == .active, if scenePhase == .active,
!appChromeModel.showBluetoothAlert { !appChromeModel.showBluetoothAlert,
!voiceRecordingVM.showAlert {
privateConversationModel.endConversation() privateConversationModel.endConversation()
} }
} }
@@ -328,7 +365,7 @@ struct ContentView: View {
ImagePreviewView(url: url) ImagePreviewView(url: url)
} }
} }
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { .alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {} Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied { if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") { 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") @Test("Root Bluetooth alert waits for screenshot privacy alert")
@MainActor @MainActor
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() { func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {