diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..4b81ed65 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -100,11 +100,14 @@ fun ChatScreen(viewModel: ChatViewModel) { var isScrolledUp by remember { mutableStateOf(false) } LaunchedEffect(selectedPrivatePeer) { - messageText = TextFieldValue( - selectedPrivatePeer - ?.let(viewModel::conversationDraft) - .orEmpty() - ) + val draft = selectedPrivatePeer + ?.let(viewModel::conversationDraft) + .orEmpty() + messageText = TextFieldValue(draft) + // Setting the field in code does not run onMessageTextChange, so the + // popups have to be re-synced with the restored draft here. + viewModel.updateCommandSuggestions(draft) + viewModel.updateMentionSuggestions(draft) } // Show password dialog when needed @@ -342,6 +345,14 @@ fun ChatScreen(viewModel: ChatViewModel) { text = newText, selection = TextRange(newText.length) ) + // Setting the field in code does not run onMessageTextChange, so + // the popups have to be re-synced with the new text here. The + // inserted mention ends with a space, which hides an open popup. + // The draft write is skipped the same way; without it, switching + // conversations restores the field without the tapped-in mention. + viewModel.setConversationDraft(selectedPrivatePeer, newText) + viewModel.updateCommandSuggestions(newText) + viewModel.updateMentionSuggestions(newText) }, onMessageLongPress = { message -> // Message long press - open user action sheet with message context @@ -431,6 +442,12 @@ fun ChatScreen(viewModel: ChatViewModel) { text = commandText, selection = TextRange(commandText.length) ) + // A code-driven edit skips onMessageTextChange: persist the + // draft and re-sync both popups, same as the other + // programmatic-edit sites. + viewModel.setConversationDraft(selectedPrivatePeer, commandText) + viewModel.updateCommandSuggestions(commandText) + viewModel.updateMentionSuggestions(commandText) }, onMentionSuggestionClick = { mention: String -> val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text) @@ -438,6 +455,12 @@ fun ChatScreen(viewModel: ChatViewModel) { text = mentionText, selection = TextRange(mentionText.length) ) + // A code-driven edit skips onMessageTextChange: persist the + // draft and re-sync both popups, same as the other + // programmatic-edit sites. + viewModel.setConversationDraft(selectedPrivatePeer, mentionText) + viewModel.updateCommandSuggestions(mentionText) + viewModel.updateMentionSuggestions(mentionText) }, selectedPrivatePeer = null, currentChannel = currentChannel, diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 2e52b750..99d899c5 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -208,4 +208,62 @@ class CommandProcessorTest { assertTrue(chatState.getMentionSuggestionsValue().isEmpty()) } + + @Test + fun `text set to an inserted mention hides both popups`() { + // A command popup is open while the user taps a nickname in the message list. + commandProcessor.updateCommandSuggestions("/") + assertTrue(chatState.getShowCommandSuggestionsValue()) + + // The composer re-syncs both popups with the inserted text, which ends in a + // space, so neither popup matches it. + commandProcessor.updateCommandSuggestions("/ @alice ") + commandProcessor.updateMentionSuggestions("/ @alice ", meshService, viewModel = null) + assertFalse(chatState.getShowCommandSuggestionsValue()) + assertTrue(chatState.getCommandSuggestionsValue().isEmpty()) + assertFalse(chatState.getShowMentionSuggestionsValue()) + assertTrue(chatState.getMentionSuggestionsValue().isEmpty()) + } + + @Test + fun `a mention followed by a space no longer matches the mention popup`() { + whenever(meshService.myPeerID).thenReturn("me") + whenever(meshService.getPeerNicknames()).thenReturn(mapOf("peer-1" to "alice")) + + commandProcessor.updateMentionSuggestions("@a", meshService, viewModel = null) + assertTrue(chatState.getShowMentionSuggestionsValue()) + + commandProcessor.updateMentionSuggestions("@alice ", meshService, viewModel = null) + assertFalse(chatState.getShowMentionSuggestionsValue()) + assertTrue(chatState.getMentionSuggestionsValue().isEmpty()) + } + + @Test + fun `command suggestions close once the input grows past the command`() { + // The command popup only shows while the whole input is a prefix of a + // command name or alias, and none of them contains a space or an @. An + // input that can match the mention popup has already closed the command + // popup. + commandProcessor.updateCommandSuggestions("/m") + assertTrue(chatState.getShowCommandSuggestionsValue()) + + commandProcessor.updateCommandSuggestions("/msg @ali") + assertFalse(chatState.getShowCommandSuggestionsValue()) + assertTrue(chatState.getCommandSuggestionsValue().isEmpty()) + } + + @Test + fun `a restored command draft reopens the command popup`() { + // Switching conversations restores the draft in code; re-syncing with the + // restored text brings the popup back for a command draft. + commandProcessor.updateCommandSuggestions("/j") + assertTrue(chatState.getShowCommandSuggestionsValue()) + assertTrue(chatState.getCommandSuggestionsValue().isNotEmpty()) + + // An empty restored draft leaves both popups hidden. + commandProcessor.updateCommandSuggestions("") + commandProcessor.updateMentionSuggestions("", meshService, viewModel = null) + assertFalse(chatState.getShowCommandSuggestionsValue()) + assertFalse(chatState.getShowMentionSuggestionsValue()) + } }