diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index df467075..8c669bcc 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -351,6 +351,11 @@ final class ConversationStore: ObservableObject { private let storage: UserDefaults private let lastActiveKey = "conversation.lastActive" + /// True for the duration of a panic wipe. Suppresses `persistLastActive()` + /// so the selection/channel resets the wipe performs cannot re-write the + /// pointer we are about to remove. + private var isPanicWiping = false + /// Snapshot of the persisted value read once at init, before any launch /// writer (e.g. `GeoChannelCoordinator` re-applying the location channel) /// can overwrite the key. Restore decisions read this, never the disk. @@ -404,6 +409,9 @@ final class ConversationStore: ObservableObject { /// single-writer selection paths on every switch; an open DM wins over /// the active channel. private func persistLastActive() { + // Panic wipe suppresses last-active persistence so the selection/channel + // resets that follow it can't re-write the pointer being cleared. + guard !isPanicWiping else { return } let record: LastActiveRecord if let peerID = selectedPrivatePeerID { record = LastActiveRecord(kind: .direct, peerID: peerID.id) @@ -425,6 +433,17 @@ final class ConversationStore: ObservableObject { storage.removeObject(forKey: lastActiveKey) } + /// Begins a panic wipe: suppress last-active persistence so the selection/channel + /// resets that follow cannot re-write the pointer we're about to remove. + func beginPanicWipe() { isPanicWiping = true } + + /// Finishes a panic wipe: remove the persisted pointer and re-enable persistence. + /// MUST be called AFTER all selection/channel state has been reset. + func finishPanicWipe() { + clearPersistedLastActive() + isPanicWiping = false + } + /// Decides what to present at launch from the value persisted last /// session. Pure aside from reading the init snapshot: performs no /// selection mutation and never writes `activeChannel`, so it cannot race diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 15088037..541509b2 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1177,9 +1177,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // single-writer ConversationStore; the derived `messages` view and // the legacy mirror empty with it) conversations.clearAll() - // Also erase the persisted last-active pointer (#1064) so a wiped - // DM/channel cannot be restored on the next launch. - conversations.clearPersistedLastActive() + // Begin suppressing last-active persistence (#1064). The selection and + // channel resets below route through the store's setters, which would + // otherwise re-persist a `.mesh` pointer; the wipe is finished (and the + // pointer removed once) at the very end of this method. + conversations.beginPanicWipe() pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) @@ -1307,6 +1309,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele #endif } + // Finish the panic wipe (#1064): must run AFTER `selectedPrivateChatPeer + // = nil` / `activeChannel = .mesh` above so no setter re-persists the + // pointer. Removes the last-active key once and re-enables persistence, + // leaving the key ABSENT so next launch hits the conversation-list + // first-launch fallback. + conversations.finishPanicWipe() + // Force immediate UI update for panic mode // UI updates immediately - no flushing needed diff --git a/bitchatTests/ConversationStoreLastActiveTests.swift b/bitchatTests/ConversationStoreLastActiveTests.swift index faf5adc4..9f4523bd 100644 --- a/bitchatTests/ConversationStoreLastActiveTests.swift +++ b/bitchatTests/ConversationStoreLastActiveTests.swift @@ -112,6 +112,48 @@ final class ConversationStoreLastActiveTests: XCTestCase { ) } + func test_panicWipeOrderLeavesNoRestoreRecord() { + // #1064 panic wipe, FULL ORDER: `clearPersistedLastActive()` alone is + // insufficient because `panicClearAllData()` also runs + // `selectedPrivateChatPeer = nil` and `activeChannel = .mesh` AFTER it — + // both route through the store's setters, whose guarded `persistLastActive()` + // re-writes a `.mesh` record on a real state change, resurrecting the + // pointer. The begin/finish suppression window fixes that. This test + // mirrors panicClearAllData's exact order against a single store: + // beginPanicWipe → setSelectedPrivatePeer(nil) → setActiveChannel(.mesh) → finishPanicWipe + // Without the fix (i.e. without begin/finish), those selection/channel + // resets re-persist a `.mesh` record — here `setSelectedPrivatePeer(nil)` + // is the real state change that writes it (the store's default channel + // is already `.mesh`, so `setActiveChannel(.mesh)` is the second trigger + // whenever panic runs from a non-mesh channel). Either way the pointer + // survives as `.mesh` and the next launch would `.deferToChannelRestore` + // instead of `.conversationList`. + let storage = makeStorage() + + // The user is in a DM: a `.direct` record is persisted and would restore. + let session = ConversationStore(storage: storage) + session.setSelectedPrivatePeer(peerID) + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .restoredDirectChat(peerID) + ) + + // Reproduce panicClearAllData's order on the SAME store. + session.beginPanicWipe() + session.setSelectedPrivatePeer(nil) // re-persist trigger #1 (suppressed) + session.setActiveChannel(.mesh) // re-persist trigger #2 (suppressed) + session.finishPanicWipe() // removes the pointer once + + // A fresh store on the SAME storage finds NO record and falls back to + // the conversation list — the pointer is truly absent, not `.mesh`. + XCTAssertEqual( + ConversationStore(storage: storage) + .restoreLastActiveConversation(isPeerResolvable: { _ in true }), + .conversationList + ) + } + func test_firstLaunchPresentsConversationList() { let storage = makeStorage()