From 66536063ca7e3f124aa6a1fb8a04e5ecf81e9025 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:26:31 +0200 Subject: [PATCH] Route command output to the conversation where the command was typed (#1363) CommandProcessor results (/help text, errors like "unknown command", /msg confirmations) were always appended to the public timeline via addSystemMessage, so a command typed inside a DM appeared to do nothing until the user switched back to the public channel. handleCommand now routes .success/.error output to the open private chat when one is selected, falling back to the public timeline otherwise. The DM selection is read after processing so commands that switch chats (/msg) print into the conversation they just opened. Follow-up to #1354, which added /help and surfaced this routing gap. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/ViewModels/ChatViewModel.swift | 19 +++++++-- bitchatTests/ChatViewModelTests.swift | 53 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) 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