Add support for slash commands (under Feature Flag) (#6482)

* Add support for slash commands

* Update screenshots

* Rename module `slash` to `slashcommands`

* Rename `SlashCommand` to `SlashCommandService`

* Introduce MsgType in order to send text message with a different msgtype value.

* Format file and add parameter names, add default values and cleanup

* Add isSupported parameter to filter out unsupported yet commands.

* Slash commands: disable suggestions if the feature is disabled.

* Fix sending shrug command.

* Add missing test on SuggestionsProcessor

* Add tests on MessageComposerPresenter about slash command.

* Fix import ordering

* Add missing tests on CommandExecutor

* Add missing tests in MarkdownTextEditorStateTest

* Slash commands: Improve code when sending message with prefix.

* Slash commands: Add support for /unflip

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2026-04-02 16:15:32 +02:00 committed by GitHub
parent 3634b5593c
commit 4ad495d36c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3038 additions and 86 deletions

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
@Immutable
sealed interface ResolvedSuggestion {
@ -32,4 +33,8 @@ sealed interface ResolvedSuggestion {
size = size,
)
}
data class Command(
val command: SlashCommandSuggestion,
) : ResolvedSuggestion
}

View file

@ -61,21 +61,29 @@ class MarkdownTextEditorState(
}
is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
val userId = resolvedSuggestion.roomMember.userId
val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
val roomAlias = resolvedSuggestion.roomAlias
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Command -> {
// Just insert the command text
text.update("${resolvedSuggestion.command.command} ", true)
val length = resolvedSuggestion.command.command.length + 1
selection = IntRange(length, length)
}
}
}

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionType
@ -42,6 +43,7 @@ class MarkdownTextEditorStateTest {
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
assertThat(state.text.value().toString()).isEqualTo("Hello @")
}
@Test
@ -53,6 +55,7 @@ class MarkdownTextEditorStateTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("Hello # ")
}
@Test
@ -64,6 +67,19 @@ class MarkdownTextEditorStateTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("Hello # ")
}
@Test
fun `insertSuggestion - command`() {
val state = aMarkdownTextEditorState(initialText = "/rai", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 0, end = 3, type = SuggestionType.Command, text = "/rainbow")
}
val suggestion = aSlashCommandSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("/rainbow ")
}
@Test
@ -74,6 +90,7 @@ class MarkdownTextEditorStateTest {
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(mention, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
assertThat(state.text.value().toString()).isEqualTo("Hello @")
}
@Test
@ -91,6 +108,7 @@ class MarkdownTextEditorStateTest {
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
}
@Test
@ -107,15 +125,14 @@ class MarkdownTextEditorStateTest {
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
}
@Test
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
val text = "No mentions here"
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
assertThat(markdown).isEqualTo(text)
}
@ -128,19 +145,17 @@ class MarkdownTextEditorStateTest {
)
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" +
" and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)"
)
assertThat(state.text.value().toString()).isEqualTo("Hello @ and everyone in @ and a room #room:domain.org")
}
@Test
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
assertThat(state.getMentions()).isEmpty()
}
@ -148,9 +163,7 @@ class MarkdownTextEditorStateTest {
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
@ -184,4 +197,14 @@ class MarkdownTextEditorStateTest {
roomAvatarUrl = null
)
}
private fun aSlashCommandSuggestion(): ResolvedSuggestion.Command {
return ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/rainbow",
parameters = "param",
description = "Make the text colorful 🌈",
),
)
}
}