diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a38dc529..51e93024 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1435,7 +1435,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash - /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help + /// - Note: Supports commands like /msg, /who, /slap, /clear, /help @MainActor func handleCommand(_ command: String) { let result = commandProcessor.process(command) @@ -1443,16 +1443,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele switch result { case .success(let message): if let msg = message { - addSystemMessage(msg) + addCommandOutput(msg) } case .error(let message): - addSystemMessage(message) + addCommandOutput(message) case .handled: // Command was handled, no message needed break } } + /// Command output belongs in the conversation where the user typed the + /// command; the public timeline is invisible while a DM is open. The DM + /// selection is read *after* processing so commands that switch chats + /// (`/msg`) print into the conversation they just opened. + @MainActor + private func addCommandOutput(_ content: String) { + if let peerID = selectedPrivateChatPeer { + addLocalPrivateSystemMessage(content, to: peerID) + } else { + addSystemMessage(content) + } + } + // MARK: - Message Reception @MainActor diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 3e9c2daa..5a50f0ac 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -263,6 +263,59 @@ struct ChatViewModelCommandTests { #expect(transport.sentPrivateMessages.isEmpty) } } + + @Test @MainActor + func handleCommand_outputRoutesToOpenPrivateChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleCommand("/help") + + #expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText) + #expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText }) + } + + @Test @MainActor + func handleCommand_errorRoutesToOpenPrivateChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleCommand("/bogus") + + let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? [] + #expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") }) + #expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") }) + } + + @Test @MainActor + func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.handleCommand("/bogus") + + #expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true) + } + + @Test @MainActor + func handleCommand_msgSuccessLandsInNewlyOpenedChat() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + let resolved = await TestHelpers.waitUntil({ + viewModel.getPeerIDForNickname("Alice") == peerID + }, timeout: TestConstants.defaultTimeout) + #expect(resolved) + + viewModel.handleCommand("/msg Alice") + + #expect(viewModel.selectedPrivateChatPeer == peerID) + #expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice") + #expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" }) + } } // MARK: - Composer Tests