From 750abafaadb9422c3245e89cde9e4f5212c70e4f Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:07:01 -0500 Subject: [PATCH] Re-sync suggestion popups when the composer text is set in code When a suggestion popup is open and the app sets the composer text itself, the popup stays up over the new text. The popups are updated only from the field's text-change handler, which a code-driven change does not run. PR #847 fixed this for the send path, the case #250 reported. While working on that fix, two more places with the same older bug were found: tapping a nickname in the message list, which inserts "@name " into the field, and switching private conversations, which restores that conversation's draft. Each site now calls updateCommandSuggestions and updateMentionSuggestions with the text it just set, the same calls the text-change handler makes while typing. The popup state then always matches the field content: an inserted mention ends with a space, so an open popup hides, and a restored draft that starts with "/" brings the command popup back the way typing it would. The iOS app already works this way: its composer drives autocomplete from onChange(of: messageText), which runs for code-driven changes too, so a mention insert or a restored draft re-syncs the popup there. Tests in CommandProcessorTest pin the inserted-mention shape, the trailing-space rule, and the draft-restore behavior. The unit tests do not drive Compose, so they cover the functions the two sites call rather than the sites themselves. Full Android unit suite green; lint and the debug build pass. --- .../java/com/bitchat/android/ui/ChatScreen.kt | 18 +++++--- .../android/ui/CommandProcessorTest.kt | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) 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..fc4f4ccf 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,11 @@ 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. + viewModel.updateCommandSuggestions(newText) + viewModel.updateMentionSuggestions(newText) }, onMessageLongPress = { message -> // Message long press - open user action sheet with message context 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 36c68429..16ca3f16 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -188,4 +188,48 @@ class CommandProcessorTest() { assertFalse(chatState.getShowMentionSuggestionsValue()) 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 `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()) + } }