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.
This commit is contained in:
heyaim 2026-08-01 00:07:01 -05:00
parent b8c7470ace
commit 750abafaad
2 changed files with 57 additions and 5 deletions

View File

@ -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

View File

@ -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())
}
}