fix: single showSidebar latch for launch list, drop one-shot flag (#1064)

The launch conversation-list signal was a separate `AppChromeModel`
one-shot, `presentsConversationListOnLaunch`, OR'd only into the people-sheet
presentation binding. The competing root sheets/covers — the fingerprint sheet
and the image pickers — gate on `!showSidebar && selectedPrivatePeerID == nil`
and never subtracted that launch term, so when the launch flag raised the
people sheet while `showSidebar` was still false, both the people sheet and (if
`showingFingerprintFor != nil`) the fingerprint sheet evaluated their
`isPresented` binding to true at once — a SwiftUI single-sheet conflict.

Rather than thread the launch term through every competing binding, consume the
launch signal once through the existing `showSidebar` latch. `showSidebar` moves
from a `ContentView` `@State` local onto `AppChromeModel` (`ContentView` already
observes it as an `@EnvironmentObject`, and `$appChromeModel.showSidebar`
yields the same `Binding<Bool>` the child views were already handed), so
non-view launch code in `AppRuntime` can raise it directly. Every competing
sheet already keys off `showSidebar`, so with the launch signal folded into that
one latch there is no separate flag to reconcile and the collision cannot occur.
`presentsConversationListOnLaunch` and its every reference are deleted.

Behavior-preserving: `showSidebar` had a single owning `ContentView` instance,
so promoting it to the shared chrome model does not change its lifetime or
semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ecgang 2026-07-06 10:35:31 -07:00
parent 0cc4e9821e
commit e3acdfb599
4 changed files with 24 additions and 19 deletions

View File

@ -14,10 +14,15 @@ final class AppChromeModel: ObservableObject {
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
/// #1064: set once at launch when the last-active conversation resolves to
/// "present the conversation list" (first-ever launch or a stale DM peer).
/// `ContentView` folds this into the people-sheet presentation binding.
@Published var presentsConversationListOnLaunch = false
/// Latch for the people / conversation-list sheet. Owned here (rather than as
/// `ContentView` local `@State`) so non-view launch code can raise it: on launch
/// `AppRuntime` sets this to `true` when the last-active conversation resolves to
/// "present the conversation list" (first-ever launch or a stale/unrestorable DM
/// peer). `ContentView` binds the people sheet directly to this, and every
/// competing sheet/cover already gates on `!showSidebar`, so a single latch keeps
/// the launch presentation from colliding with the fingerprint / image-picker
/// sheets (#1064).
@Published var showSidebar = false
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()

View File

@ -161,7 +161,7 @@ final class AppRuntime: ObservableObject {
// the public mesh timeline when a restore target existed but could not
// be opened.
if Self.shouldPresentConversationList(for: presentation, didOpenDirectChat: didOpenDirectChat) {
appChromeModel.presentsConversationListOnLaunch = true
appChromeModel.showSidebar = true
}
}

View File

@ -85,7 +85,7 @@ struct ContentPeopleSheetView: View {
}
}
.navigationDestination(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil || appChromeModel.presentsConversationListOnLaunch) },
get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { isPresented in
if !isPresented {
appChromeModel.clearFingerprint()
@ -105,7 +105,7 @@ struct ContentPeopleSheetView: View {
#endif
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil || appChromeModel.presentsConversationListOnLaunch) },
get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) },
set: { newValue in
if !newValue {
showImagePicker = false

View File

@ -41,7 +41,8 @@ struct ContentView: View {
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
@Environment(\.appTheme) private var appTheme
@State private var showSidebar = false
// `showSidebar` (the people/conversation-list sheet latch) lives on
// `AppChromeModel` so non-view launch code can raise it; see that property.
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@FocusState private var isNicknameFieldFocused: Bool
@ -97,16 +98,15 @@ struct ContentView: View {
#endif
.onChange(of: selectedPrivatePeerID) { newValue in
if newValue != nil {
showSidebar = true
appChromeModel.showSidebar = true
}
}
.sheet(
isPresented: Binding(
get: { showSidebar || selectedPrivatePeerID != nil || appChromeModel.presentsConversationListOnLaunch },
get: { appChromeModel.showSidebar || selectedPrivatePeerID != nil },
set: { isPresented in
if !isPresented {
showSidebar = false
appChromeModel.presentsConversationListOnLaunch = false
appChromeModel.showSidebar = false
privateConversationModel.endConversation()
}
}
@ -114,7 +114,7 @@ struct ContentView: View {
) {
#if os(iOS)
ContentPeopleSheetView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
@ -132,7 +132,7 @@ struct ContentView: View {
)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
@ -153,7 +153,7 @@ struct ContentView: View {
AppInfoView()
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil },
get: { appChromeModel.showingFingerprintFor != nil && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { _ in appChromeModel.clearFingerprint() }
)) {
if let peerID = appChromeModel.showingFingerprintFor {
@ -163,7 +163,7 @@ struct ContentView: View {
}
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil },
get: { showImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { newValue in
if !newValue {
showImagePicker = false
@ -179,7 +179,7 @@ struct ContentView: View {
#endif
#if os(macOS)
.sheet(isPresented: Binding(
get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil },
get: { showMacImagePicker && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
set: { newValue in
if !newValue {
showMacImagePicker = false
@ -268,7 +268,7 @@ struct ContentView: View {
private var headerView: some View {
ContentHeaderView(
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
@ -289,7 +289,7 @@ struct ContentView: View {
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
showSidebar: $appChromeModel.showSidebar,
isTextFieldFocused: $isTextFieldFocused
)
}