From 3fa1a90f23ab527d8a973924ef3748e351ff98b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 5 May 2026 14:01:11 +0100 Subject: [PATCH] Sort instant commands universal-first preserving backend order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends sortedByAvailability with a tier tie-breaker so command suggestions surface universal commands (set != moderation_set) before contextual ones. The sort is stable, so within each tier the input order from the backend's Channel.config.commands is preserved — no client-side opinion on relative order between commands of the same tier. Affects the Compose composer suggestion list (via MessageComposerController.orderedForComposer) and the Compose attachment command picker (AttachmentCommandPicker), which both call into sortedByAvailability. --- .../composer/MessageComposerController.kt | 4 +-- .../messages/composer/CommandAvailability.kt | 12 +++++-- .../composer/MessageComposerControllerTest.kt | 32 +++++++++++++------ 3 files changed, 33 insertions(+), 15 deletions(-) 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