mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 18:45:22 +00:00
Keep open DMs alive across Bluetooth settings (#1460)
Presenting the root Bluetooth alert while the people/DM sheet was up forced SwiftUI to dismiss the sheet, whose binding setter called endConversation() — destroying the open DM. The root alert is now gated behind a modal-presentation guard (scene active, no competing modal), a second copy presents from inside the sheet, and the sheet-dismissal setter only ends the conversation when the dismissal is genuinely explicit. Includes review fix: the voice-recording error alert (mic permission denied inside an open DM) destroyed the DM through the exact same path — it now participates in the same guard and gating pattern at both root and sheet level.
This commit is contained in:
@@ -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<Bool>.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<Bool> {
|
||||
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<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 {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Bool> {
|
||||
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<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 {
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user