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 f08d1ed686
commit a77662421c
65 changed files with 3038 additions and 86 deletions

View file

@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
is Id -> roomId.value
is Alias -> roomAlias.value
}
companion object {
fun from(id: String): RoomIdOrAlias? {
return when {
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
else -> null
}
}
}
}
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)

View file

@ -17,3 +17,18 @@ interface MxcTools {
*/
fun mxcUri2FilePath(mxcUri: String): String?
}
/**
* "mxc" scheme, including "://". So "mxc://".
*/
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
/**
* Return true if the String starts with "mxc://".
*/
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
/**
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
*/
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.timeline
enum class MsgType {
MSG_TYPE_TEXT,
MSG_TYPE_EMOTE,
// For future support
MSG_TYPE_SNOW,
// For future support
MSG_TYPE_CONFETTI,
}

View file

@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): Result<Unit>
suspend fun editMessage(
@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result<Unit>
suspend fun sendImage(

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
@ -271,8 +272,16 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
asPlainText = asPlainText,
).use { content ->
runCatchingExceptions<Unit> {
inner.send(content)
}
@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
)
inner.sendReply(
msg = msg,
eventId = repliedToEventId.value,

View file

@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.MessageContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
/**
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(intentionalMentions.map())
fun from(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): RoomMessageEventContentWithoutRelation {
return when {
asPlainText -> contentWithoutRelationFromMessage(
MessageContent(
msgType = MessageType.Text(
TextMessageContent(
body = body,
formatted = null,
)
),
body = body,
isEdited = false,
mentions = null,
)
)
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromHtmlAsEmote(body, htmlBody)
} else {
messageEventContentFromHtml(body, htmlBody)
}
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromMarkdownAsEmote(body)
} else {
messageEventContentFromMarkdown(body)
}
}
.withMentions(intentionalMentions.map())
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -64,7 +65,9 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError()
}
@ -76,8 +79,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions)
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError()
}
@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
msgType,
)
var sendImageLambda: (