diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index eb313c56afe..ebd709af506 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -1125,8 +1125,8 @@ public class MessageComposerController( } /** - * Sorts by availability when [Config.activeCommandEnabled] is `true`; returns the list - * unchanged in legacy mode. + * Applies [sortedByAvailability] when [Config.activeCommandEnabled] is `true`; returns the + * list unchanged in legacy mode. */ private fun List.orderedForComposer(): List = if (config.activeCommandEnabled) sortedByAvailability(activeAction) else this diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/CommandAvailability.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/CommandAvailability.kt index d8afcbc9a97..ca87c91f84a 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/CommandAvailability.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/CommandAvailability.kt @@ -42,14 +42,20 @@ public fun Command.isAvailableFor(action: MessageAction?): Boolean = when (actio } /** - * Returns a new list with commands available for [action] first, followed by unavailable ones. - * The sort is stable, so the original order is preserved within each availability group. + * Returns a new list sorted by, in order: + * 1. Availability for [action] (available first). + * 2. Universal commands (`set != "moderation_set"`) before contextual ones. + * + * The sort is stable, so within each group the input order is preserved. * * @param action The composer action currently active, or `null` when the composer is in its * default state. */ @InternalStreamChatApi public fun List.sortedByAvailability(action: MessageAction?): List = - sortedByDescending { it.isAvailableFor(action) } + sortedWith( + compareByDescending { it.isAvailableFor(action) } + .thenByDescending { it.set != MODERATION_COMMAND_SET }, + ) private const val MODERATION_COMMAND_SET: String = "moderation_set" diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index ef1bf4b7db7..20fddc213aa 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -1081,10 +1081,15 @@ internal class MessageComposerControllerTest { } @Test - fun `Given slash typed When action becomes Reply Then suggestions re-sort by availability`() = runTest { - // Given - val muteCommand = randomCommand(set = MODERATION_SET) - val funCommand = randomCommand() + fun `Given slash typed Then suggestions are universal-first preserving backend order regardless of action`() = runTest { + // Given the backend hands out commands in an order where a moderation command precedes a + // universal one — the SDK's sort must move universal commands to the front while + // preserving the backend's relative order within each tier. + val banCommand = randomCommand(name = "ban", set = MODERATION_SET) + val giphyCommand = randomCommand(name = "giphy", set = "") + val muteCommand = randomCommand(name = "mute", set = MODERATION_SET) + val unbanCommand = randomCommand(name = "unban", set = MODERATION_SET) + val unmuteCommand = randomCommand(name = "unmute", set = MODERATION_SET) val repliedMessage = randomMessage(cid = CID) val controller = Fixture() .givenConfig(MessageComposerController.Config(activeCommandEnabled = true)) @@ -1093,19 +1098,26 @@ internal class MessageComposerControllerTest { .givenClientState(randomUser()) .givenGlobalState() .givenChannelState( - configState = MutableStateFlow(Config(commands = listOf(muteCommand, funCommand))), + configState = MutableStateFlow( + Config( + commands = listOf(banCommand, giphyCommand, muteCommand, unbanCommand, unmuteCommand), + ), + ), ) .get() + val expectedOrder = listOf(giphyCommand, banCommand, muteCommand, unbanCommand, unmuteCommand) + + // When typing slash with the composer in its default state controller.setMessageInput("/") advanceUntilIdle() - assertEquals(listOf(muteCommand, funCommand), controller.state.value.commandSuggestions) - // When + // Then suggestions surface universal commands first, with backend order preserved within each tier. + assertEquals(expectedOrder, controller.state.value.commandSuggestions) + + // And switching to Reply mode preserves the same order — no re-shuffle. controller.performMessageAction(Reply(repliedMessage)) advanceUntilIdle() - - // Then - assertEquals(listOf(funCommand, muteCommand), controller.state.value.commandSuggestions) + assertEquals(expectedOrder, controller.state.value.commandSuggestions) } @Test