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:
@@ -422,6 +422,10 @@ class LoggedInFlowNode(
|
|||||||
override fun navigateToGlobalNotificationSettings() {
|
override fun navigateToGlobalNotificationSettings() {
|
||||||
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.DeveloperSettings))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val inputs = RoomFlowNode.Inputs(
|
val inputs = RoomFlowNode.Inputs(
|
||||||
roomIdOrAlias = navTarget.roomIdOrAlias,
|
roomIdOrAlias = navTarget.roomIdOrAlias,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
|
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
|
||||||
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||||
fun navigateToGlobalNotificationSettings()
|
fun navigateToGlobalNotificationSettings()
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
@@ -145,6 +146,10 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
callback.navigateToGlobalNotificationSettings()
|
callback.navigateToGlobalNotificationSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
|
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
|
||||||
callback.navigateToRoom(roomId, serverNames)
|
callback.navigateToRoom(roomId, serverNames)
|
||||||
}
|
}
|
||||||
@@ -252,6 +257,10 @@ class JoinedRoomLoadedFlowNode(
|
|||||||
override fun navigateToRoom(roomId: RoomId) {
|
override fun navigateToRoom(roomId: RoomId) {
|
||||||
callback.navigateToRoom(roomId, emptyList())
|
callback.navigateToRoom(roomId, emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val params = MessagesEntryPoint.Params(
|
val params = MessagesEntryPoint.Params(
|
||||||
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId)
|
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId)
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
|
|||||||
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
|
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
|
||||||
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
||||||
override fun navigateToGlobalNotificationSettings() = lambdaError()
|
override fun navigateToGlobalNotificationSettings() = lambdaError()
|
||||||
|
override fun navigateToDeveloperSettings() = lambdaError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
package io.element.android.features.location.impl.share
|
package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
@@ -37,6 +39,7 @@ import io.element.android.tests.testutils.WarmUpRule
|
|||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
|||||||
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||||
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
|
fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean)
|
||||||
fun navigateToRoom(roomId: RoomId)
|
fun navigateToRoom(roomId: RoomId)
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Params(val initialTarget: InitialTarget) : NodeInputs
|
data class Params(val initialTarget: InitialTarget) : NodeInputs
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.libraries.recentemojis.api)
|
implementation(projects.libraries.recentemojis.api)
|
||||||
implementation(projects.libraries.roomselect.api)
|
implementation(projects.libraries.roomselect.api)
|
||||||
|
implementation(projects.libraries.slashcommands.api)
|
||||||
implementation(projects.libraries.audio.api)
|
implementation(projects.libraries.audio.api)
|
||||||
implementation(projects.libraries.voiceplayer.api)
|
implementation(projects.libraries.voiceplayer.api)
|
||||||
implementation(projects.libraries.voicerecorder.api)
|
implementation(projects.libraries.voicerecorder.api)
|
||||||
@@ -104,4 +105,5 @@ dependencies {
|
|||||||
testImplementation(projects.features.poll.test)
|
testImplementation(projects.features.poll.test)
|
||||||
testImplementation(projects.libraries.eventformatter.test)
|
testImplementation(projects.libraries.eventformatter.test)
|
||||||
testImplementation(projects.libraries.recentemojis.test)
|
testImplementation(projects.libraries.recentemojis.test)
|
||||||
|
testImplementation(projects.libraries.slashcommands.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,10 @@ class MessagesFlowNode(
|
|||||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||||
@@ -502,6 +506,10 @@ class MessagesFlowNode(
|
|||||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface MessagesNavigator {
|
|||||||
fun navigateToEditPoll(eventId: EventId)
|
fun navigateToEditPoll(eventId: EventId)
|
||||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||||
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List<String>)
|
||||||
|
fun navigateToMember(userId: UserId)
|
||||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
fun close()
|
fun close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class MessagesNode(
|
|||||||
private val timelineController = TimelineController(room, room.liveTimeline)
|
private val timelineController = TimelineController(room, room.liveTimeline)
|
||||||
private val presenter = presenterFactory.create(
|
private val presenter = presenterFactory.create(
|
||||||
navigator = this,
|
navigator = this,
|
||||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false),
|
||||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||||
actionListPresenter = actionListPresenterFactory.create(
|
actionListPresenter = actionListPresenterFactory.create(
|
||||||
postProcessor = TimelineItemActionPostProcessor.Default,
|
postProcessor = TimelineItemActionPostProcessor.Default,
|
||||||
@@ -130,6 +130,7 @@ class MessagesNode(
|
|||||||
fun navigateToRoomDetails()
|
fun navigateToRoomDetails()
|
||||||
fun navigateToPinnedMessagesList()
|
fun navigateToPinnedMessagesList()
|
||||||
fun navigateToKnockRequestsList()
|
fun navigateToKnockRequestsList()
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
@@ -222,10 +223,18 @@ class MessagesNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToMember(userId: UserId) {
|
||||||
|
callback.navigateToRoomMemberDetails(userId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
callback.navigateToThread(threadRootId, focusedEventId)
|
callback.navigateToThread(threadRootId, focusedEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
|
|
||||||
private fun displaySameRoomToast() {
|
private fun displaySameRoomToast() {
|
||||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,4 +36,5 @@ sealed interface MessageComposerEvent {
|
|||||||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
|
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent
|
||||||
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
|
data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent
|
||||||
data object SaveDraft : MessageComposerEvent
|
data object SaveDraft : MessageComposerEvent
|
||||||
|
data object ClearSlashError : MessageComposerEvent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -33,12 +34,14 @@ import im.vector.app.features.analytics.plan.Interaction
|
|||||||
import io.element.android.features.location.api.LocationService
|
import io.element.android.features.location.api.LocationService
|
||||||
import io.element.android.features.messages.impl.MessagesNavigator
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.attachments.Attachment.Media
|
||||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
||||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
@@ -68,6 +71,9 @@ import io.element.android.libraries.permissions.api.PermissionsEvent
|
|||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
|
import io.element.android.libraries.slashcommands.api.message
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||||
@@ -104,6 +110,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
|||||||
class MessageComposerPresenter(
|
class MessageComposerPresenter(
|
||||||
@Assisted private val navigator: MessagesNavigator,
|
@Assisted private val navigator: MessagesNavigator,
|
||||||
@Assisted private val timelineController: TimelineController,
|
@Assisted private val timelineController: TimelineController,
|
||||||
|
@Assisted private val isInThread: Boolean,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
@@ -125,10 +132,15 @@ class MessageComposerPresenter(
|
|||||||
private val suggestionsProcessor: SuggestionsProcessor,
|
private val suggestionsProcessor: SuggestionsProcessor,
|
||||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||||
private val notificationConversationService: NotificationConversationService,
|
private val notificationConversationService: NotificationConversationService,
|
||||||
|
private val slashCommandService: SlashCommandService,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter
|
fun create(
|
||||||
|
timelineController: TimelineController,
|
||||||
|
navigator: MessagesNavigator,
|
||||||
|
isInThread: Boolean,
|
||||||
|
): MessageComposerPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
|
private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode())
|
||||||
@@ -218,6 +230,8 @@ class MessageComposerPresenter(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val slashCommandAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val draft = draftService.loadDraft(
|
val draft = draftService.loadDraft(
|
||||||
roomId = room.roomId,
|
roomId = room.roomId,
|
||||||
@@ -246,12 +260,13 @@ class MessageComposerPresenter(
|
|||||||
sessionCoroutineScope.sendMessage(
|
sessionCoroutineScope.sendMessage(
|
||||||
markdownTextEditorState = markdownTextEditorState,
|
markdownTextEditorState = markdownTextEditorState,
|
||||||
richTextEditorState = richTextEditorState,
|
richTextEditorState = richTextEditorState,
|
||||||
|
slashCommandAction = slashCommandAction,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MessageComposerEvent.SendUri -> {
|
is MessageComposerEvent.SendUri -> {
|
||||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||||
sessionCoroutineScope.sendAttachment(
|
sessionCoroutineScope.sendAttachment(
|
||||||
attachment = Attachment.Media(
|
attachment = Media(
|
||||||
localMedia = localMediaFactory.createFromUri(
|
localMedia = localMediaFactory.createFromUri(
|
||||||
uri = event.uri,
|
uri = event.uri,
|
||||||
mimeType = null,
|
mimeType = null,
|
||||||
@@ -340,6 +355,9 @@ class MessageComposerPresenter(
|
|||||||
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
|
val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch
|
||||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||||
}
|
}
|
||||||
|
is ResolvedSuggestion.Command -> {
|
||||||
|
richTextEditorState.replaceSuggestion(suggestion.command.command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (markdownTextEditorState.currentSuggestion != null) {
|
} else if (markdownTextEditorState.currentSuggestion != null) {
|
||||||
markdownTextEditorState.insertSuggestion(
|
markdownTextEditorState.insertSuggestion(
|
||||||
@@ -354,6 +372,9 @@ class MessageComposerPresenter(
|
|||||||
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
|
||||||
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
|
sessionCoroutineScope.updateDraft(draft, isVolatile = false)
|
||||||
}
|
}
|
||||||
|
MessageComposerEvent.ClearSlashError -> {
|
||||||
|
slashCommandAction.value = AsyncAction.Uninitialized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +406,7 @@ class MessageComposerPresenter(
|
|||||||
suggestions = suggestions.toImmutableList(),
|
suggestions = suggestions.toImmutableList(),
|
||||||
resolveMentionDisplay = resolveMentionDisplay,
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||||
|
slashCommandAction = slashCommandAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -422,6 +444,7 @@ class MessageComposerPresenter(
|
|||||||
roomAliasSuggestions = roomAliasSuggestions,
|
roomAliasSuggestions = roomAliasSuggestions,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
canSendRoomMention = ::canSendRoomMention,
|
canSendRoomMention = ::canSendRoomMention,
|
||||||
|
isInThread = isInThread,
|
||||||
)
|
)
|
||||||
suggestions.clear()
|
suggestions.clear()
|
||||||
suggestions.addAll(result)
|
suggestions.addAll(result)
|
||||||
@@ -433,9 +456,69 @@ class MessageComposerPresenter(
|
|||||||
private fun CoroutineScope.sendMessage(
|
private fun CoroutineScope.sendMessage(
|
||||||
markdownTextEditorState: MarkdownTextEditorState,
|
markdownTextEditorState: MarkdownTextEditorState,
|
||||||
richTextEditorState: RichTextEditorState,
|
richTextEditorState: RichTextEditorState,
|
||||||
|
slashCommandAction: MutableState<AsyncAction<Unit>>,
|
||||||
) = launch {
|
) = launch {
|
||||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
|
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
|
||||||
val capturedMode = messageComposerContext.composerMode
|
val capturedMode = messageComposerContext.composerMode
|
||||||
|
|
||||||
|
val slashCommand = if (capturedMode is MessageComposerMode.Normal) {
|
||||||
|
slashCommandService.parse(
|
||||||
|
textMessage = message.markdown,
|
||||||
|
formattedMessage = message.html,
|
||||||
|
isInThreadTimeline = isInThread,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SlashCommand.NotACommand
|
||||||
|
}
|
||||||
|
|
||||||
|
when (slashCommand) {
|
||||||
|
is SlashCommand.NotACommand -> Unit
|
||||||
|
is SlashCommand.Error -> {
|
||||||
|
slashCommandAction.value = AsyncAction.Failure(Exception(slashCommand.message()))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
is SlashCommand.SlashCommandNavigation -> {
|
||||||
|
when (slashCommand) {
|
||||||
|
is SlashCommand.ShowUser -> {
|
||||||
|
navigator.navigateToMember(slashCommand.userId)
|
||||||
|
}
|
||||||
|
SlashCommand.DevTools -> {
|
||||||
|
navigator.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
is SlashCommand.SlashCommandSendMessage -> {
|
||||||
|
timelineController.invokeOnCurrentTimeline {
|
||||||
|
slashCommandService.proceedSendMessage(slashCommand, this)
|
||||||
|
.onFailure { cause ->
|
||||||
|
Timber.e(cause, "Failed to proceed with admin slash command")
|
||||||
|
slashCommandAction.value = AsyncAction.Failure(cause)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
// Reset composer
|
||||||
|
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
is SlashCommand.SlashCommandAdmin -> {
|
||||||
|
slashCommandAction.value = AsyncAction.Loading
|
||||||
|
slashCommandService.proceedAdmin(slashCommand)
|
||||||
|
.onFailure { cause ->
|
||||||
|
Timber.e(cause, "Failed to proceed with admin slash command")
|
||||||
|
slashCommandAction.value = AsyncAction.Failure(cause)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
// Reset composer
|
||||||
|
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||||
|
slashCommandAction.value = AsyncAction.Uninitialized
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset composer right away
|
// Reset composer right away
|
||||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||||
when (capturedMode) {
|
when (capturedMode) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
@@ -26,5 +27,6 @@ data class MessageComposerState(
|
|||||||
val suggestions: ImmutableList<ResolvedSuggestion>,
|
val suggestions: ImmutableList<ResolvedSuggestion>,
|
||||||
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
val resolveMentionDisplay: (String, String) -> TextDisplay,
|
||||||
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
val resolveAtRoomMentionDisplay: () -> TextDisplay,
|
||||||
|
val slashCommandAction: AsyncAction<Unit>,
|
||||||
val eventSink: (MessageComposerEvent) -> Unit,
|
val eventSink: (MessageComposerEvent) -> Unit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
@@ -32,6 +33,7 @@ fun aMessageComposerState(
|
|||||||
showAttachmentSourcePicker: Boolean = false,
|
showAttachmentSourcePicker: Boolean = false,
|
||||||
canShareLocation: Boolean = true,
|
canShareLocation: Boolean = true,
|
||||||
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
|
suggestions: ImmutableList<ResolvedSuggestion> = persistentListOf(),
|
||||||
|
slashCommandAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (MessageComposerEvent) -> Unit = {},
|
eventSink: (MessageComposerEvent) -> Unit = {},
|
||||||
) = MessageComposerState(
|
) = MessageComposerState(
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
@@ -43,5 +45,6 @@ fun aMessageComposerState(
|
|||||||
suggestions = suggestions,
|
suggestions = suggestions,
|
||||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||||
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
|
||||||
|
slashCommandAction = slashCommandAction,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
|
|||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
|
||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.textcomposer.TextComposer
|
import io.element.android.libraries.textcomposer.TextComposer
|
||||||
@@ -115,6 +116,12 @@ internal fun MessageComposerView(
|
|||||||
onTyping = ::onTyping,
|
onTyping = ::onTyping,
|
||||||
onSelectRichContent = ::sendUri,
|
onSelectRichContent = ::sendUri,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AsyncActionView(
|
||||||
|
async = state.slashCommandAction,
|
||||||
|
onSuccess = {},
|
||||||
|
onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType.Room
|
||||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
@@ -40,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -63,6 +65,7 @@ fun SuggestionsPickerView(
|
|||||||
is ResolvedSuggestion.AtRoom -> "@room"
|
is ResolvedSuggestion.AtRoom -> "@room"
|
||||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||||
is ResolvedSuggestion.Alias -> suggestion.roomId.value
|
is ResolvedSuggestion.Alias -> suggestion.roomId.value
|
||||||
|
is ResolvedSuggestion.Command -> suggestion.command.command
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -91,40 +94,57 @@ private fun SuggestionItemView(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.clickable { onSelectSuggestion(suggestion) },
|
modifier = modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
.clickable { onSelectSuggestion(suggestion) }
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
val avatarSize = AvatarSize.Suggestion
|
val avatarSize = AvatarSize.Suggestion
|
||||||
val avatarData = when (suggestion) {
|
val avatarData = when (suggestion) {
|
||||||
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||||
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
|
is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize)
|
||||||
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
|
is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize)
|
||||||
|
is ResolvedSuggestion.Command -> null
|
||||||
}
|
}
|
||||||
val avatarType = when (suggestion) {
|
val avatarType = when (suggestion) {
|
||||||
is ResolvedSuggestion.Alias -> AvatarType.Room()
|
is ResolvedSuggestion.Alias -> Room()
|
||||||
ResolvedSuggestion.AtRoom,
|
ResolvedSuggestion.AtRoom,
|
||||||
is ResolvedSuggestion.Member -> AvatarType.User
|
is ResolvedSuggestion.Member -> AvatarType.User
|
||||||
|
is ResolvedSuggestion.Command -> null
|
||||||
}
|
}
|
||||||
val title = when (suggestion) {
|
val title = when (suggestion) {
|
||||||
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||||
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
|
is ResolvedSuggestion.Member -> suggestion.roomMember.displayName
|
||||||
is ResolvedSuggestion.Alias -> suggestion.roomName
|
is ResolvedSuggestion.Alias -> suggestion.roomName
|
||||||
|
is ResolvedSuggestion.Command -> suggestion.command.command
|
||||||
|
}
|
||||||
|
val details = when (suggestion) {
|
||||||
|
is ResolvedSuggestion.AtRoom,
|
||||||
|
is ResolvedSuggestion.Member,
|
||||||
|
is ResolvedSuggestion.Alias -> null
|
||||||
|
is ResolvedSuggestion.Command -> suggestion.command.parameters
|
||||||
}
|
}
|
||||||
val subtitle = when (suggestion) {
|
val subtitle = when (suggestion) {
|
||||||
is ResolvedSuggestion.AtRoom -> "@room"
|
is ResolvedSuggestion.AtRoom -> "@room"
|
||||||
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value
|
||||||
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
|
is ResolvedSuggestion.Alias -> suggestion.roomAlias.value
|
||||||
|
is ResolvedSuggestion.Command -> suggestion.command.description
|
||||||
}
|
}
|
||||||
|
if (avatarData != null && avatarType != null) {
|
||||||
Avatar(
|
Avatar(
|
||||||
avatarData = avatarData,
|
avatarData = avatarData,
|
||||||
avatarType = avatarType,
|
avatarType = avatarType,
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
|
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
|
.padding(top = 8.dp, bottom = 8.dp)
|
||||||
.align(Alignment.CenterVertically),
|
.align(Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
title?.let {
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
@@ -134,11 +154,21 @@ private fun SuggestionItemView(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
details?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
maxLines = 1,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -174,8 +204,22 @@ internal fun SuggestionsPickerViewPreview() {
|
|||||||
roomId = RoomId("!room:matrix.org"),
|
roomId = RoomId("!room:matrix.org"),
|
||||||
roomName = "My room",
|
roomName = "My room",
|
||||||
roomAvatarUrl = null,
|
roomAvatarUrl = null,
|
||||||
|
),
|
||||||
|
ResolvedSuggestion.Command(
|
||||||
|
command = SlashCommandSuggestion(
|
||||||
|
command = "/noparam",
|
||||||
|
parameters = null,
|
||||||
|
description = "A slash command without parameters",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
ResolvedSuggestion.Command(
|
||||||
|
command = SlashCommandSuggestion(
|
||||||
|
command = "/withparam",
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = "A slash command with parameters",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
onSelectSuggestion = {}
|
onSelectSuggestion = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||||
@@ -23,7 +24,9 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
|
|||||||
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
|
* This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer.
|
||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
class SuggestionsProcessor {
|
class SuggestionsProcessor(
|
||||||
|
private val slashCommandService: SlashCommandService,
|
||||||
|
) {
|
||||||
/**
|
/**
|
||||||
* Process the suggestion.
|
* Process the suggestion.
|
||||||
* @param suggestion The current suggestion input
|
* @param suggestion The current suggestion input
|
||||||
@@ -31,6 +34,7 @@ class SuggestionsProcessor {
|
|||||||
* @param roomAliasSuggestions The available room alias suggestions
|
* @param roomAliasSuggestions The available room alias suggestions
|
||||||
* @param currentUserId The current user id
|
* @param currentUserId The current user id
|
||||||
* @param canSendRoomMention Should return true if the current user can send room mentions
|
* @param canSendRoomMention Should return true if the current user can send room mentions
|
||||||
|
* @param isInThread Whether the composer is in a thread or not, used to filter slash commands suggestions
|
||||||
* @return The list of suggestions to display
|
* @return The list of suggestions to display
|
||||||
*/
|
*/
|
||||||
suspend fun process(
|
suspend fun process(
|
||||||
@@ -39,6 +43,7 @@ class SuggestionsProcessor {
|
|||||||
roomAliasSuggestions: List<RoomAliasSuggestion>,
|
roomAliasSuggestions: List<RoomAliasSuggestion>,
|
||||||
currentUserId: UserId,
|
currentUserId: UserId,
|
||||||
canSendRoomMention: suspend () -> Boolean,
|
canSendRoomMention: suspend () -> Boolean,
|
||||||
|
isInThread: Boolean,
|
||||||
): List<ResolvedSuggestion> {
|
): List<ResolvedSuggestion> {
|
||||||
suggestion ?: return emptyList()
|
suggestion ?: return emptyList()
|
||||||
return when (suggestion.type) {
|
return when (suggestion.type) {
|
||||||
@@ -69,7 +74,16 @@ class SuggestionsProcessor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SuggestionType.Command,
|
SuggestionType.Command -> {
|
||||||
|
// Command suggestions are valid only if this is the beginning of the message
|
||||||
|
if (suggestion.start == 0) {
|
||||||
|
slashCommandService.getSuggestions(suggestion.text, isInThread).map {
|
||||||
|
ResolvedSuggestion.Command(it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
SuggestionType.Emoji,
|
SuggestionType.Emoji,
|
||||||
is SuggestionType.Custom -> {
|
is SuggestionType.Custom -> {
|
||||||
// Clear suggestions
|
// Clear suggestions
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ThreadedMessagesNode(
|
|||||||
this.timelineController = timelineController
|
this.timelineController = timelineController
|
||||||
return presenterFactory.create(
|
return presenterFactory.create(
|
||||||
navigator = this,
|
navigator = this,
|
||||||
composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
|
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true),
|
||||||
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
|
||||||
// TODO add special processor for threaded timeline
|
// TODO add special processor for threaded timeline
|
||||||
actionListPresenter = actionListPresenterFactory.create(
|
actionListPresenter = actionListPresenterFactory.create(
|
||||||
@@ -136,6 +136,7 @@ class ThreadedMessagesNode(
|
|||||||
fun navigateToEditPoll(eventId: EventId)
|
fun navigateToEditPoll(eventId: EventId)
|
||||||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
@@ -233,10 +234,18 @@ class ThreadedMessagesNode(
|
|||||||
callback.handlePermalinkClick(permalinkData)
|
callback.handlePermalinkClick(permalinkData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToMember(userId: UserId) {
|
||||||
|
callback.navigateToRoomMemberDetails(userId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
callback.navigateToThread(threadRootId, focusedEventId)
|
callback.navigateToThread(threadRootId, focusedEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = navigateUp()
|
override fun close() = navigateUp()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest {
|
|||||||
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
||||||
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
||||||
override fun navigateToRoom(roomId: RoomId) = lambdaError()
|
override fun navigateToRoom(roomId: RoomId) = lambdaError()
|
||||||
|
override fun navigateToDeveloperSettings() = lambdaError()
|
||||||
}
|
}
|
||||||
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
|
val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID)
|
||||||
val params = MessagesEntryPoint.Params(initialTarget)
|
val params = MessagesEntryPoint.Params(initialTarget)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class FakeMessagesNavigator(
|
|||||||
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
||||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||||
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
|
private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List<String>) -> Unit = { _, _, _ -> lambdaError() },
|
||||||
|
private val navigateToMemberLambda: (userId: UserId) -> Unit = { lambdaError() },
|
||||||
|
private val navigateToDeveloperSettingsLambda: () -> Unit = { lambdaError() },
|
||||||
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||||
private val closeLambda: () -> Unit = { lambdaError() },
|
private val closeLambda: () -> Unit = { lambdaError() },
|
||||||
) : MessagesNavigator {
|
) : MessagesNavigator {
|
||||||
@@ -51,10 +53,18 @@ class FakeMessagesNavigator(
|
|||||||
onNavigateToRoomLambda(roomId, eventId, serverNames)
|
onNavigateToRoomLambda(roomId, eventId, serverNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToMember(userId: UserId) {
|
||||||
|
navigateToMemberLambda(userId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
|
||||||
onOpenThreadLambda(threadRootId, focusedEventId)
|
onOpenThreadLambda(threadRootId, focusedEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
navigateToDeveloperSettingsLambda()
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
closeLambda()
|
closeLambda()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import app.cash.turbine.ReceiveTurbine
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.api.LocationService
|
||||||
|
import io.element.android.features.location.test.FakeLocationService
|
||||||
|
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||||
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
|
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||||
|
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||||
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
|
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||||
|
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||||
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||||
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
|
||||||
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_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.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
|
||||||
|
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
|
||||||
|
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||||
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
|
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
|
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
|
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||||
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MessageComposerPresenterSlashCommandTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
private val pickerProvider = FakePickerProvider().apply {
|
||||||
|
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||||
|
}
|
||||||
|
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
private val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||||
|
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||||
|
private val analyticsService = FakeAnalyticsService()
|
||||||
|
private val notificationConversationService = FakeNotificationConversationService()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createPresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
assertThat(initialState.isFullScreen).isFalse()
|
||||||
|
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||||
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||||
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
|
assertThat(initialState.canShareLocation).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command error sets failure`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.ErrorUnknownSlashCommand(A_FAILURE_REASON) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.slashCommandAction.isFailure()).isTrue()
|
||||||
|
assertThat(errorState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||||
|
// Composer should not be reset when command is an error
|
||||||
|
assertThat(errorState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
|
||||||
|
// Close the error
|
||||||
|
errorState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command navigation ShowUser navigates to member and resets composer`() = runTest {
|
||||||
|
val navigateToMember = lambdaRecorder<UserId, Unit> {}
|
||||||
|
val navigator = FakeMessagesNavigator(navigateToMemberLambda = navigateToMember)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
navigator = navigator,
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.ShowUser(A_USER_ID) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
// navigation should be invoked and composer reset
|
||||||
|
navigateToMember.assertions().isCalledOnce().with(value(A_USER_ID))
|
||||||
|
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command navigation DevTools navigates to developer settings and resets composer`() = runTest {
|
||||||
|
val navigateToDev = lambdaRecorder<Unit> { }
|
||||||
|
val navigator = FakeMessagesNavigator(navigateToDeveloperSettingsLambda = navigateToDev)
|
||||||
|
val presenter = createPresenter(
|
||||||
|
navigator = navigator,
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.DevTools }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
navigateToDev.assertions().isCalledOnce()
|
||||||
|
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command send message proceeds and resets composer`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.SendPlainText(A_MESSAGE) },
|
||||||
|
proceedSendMessageResult = { _, _ -> Result.success(Unit) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
advanceUntilIdle()
|
||||||
|
// Composer reset after successful slash send
|
||||||
|
assertThat(initialState.textEditorState.messageHtml()).isEmpty()
|
||||||
|
// Ensure no failure
|
||||||
|
assertThat(initialState.slashCommandAction.isFailure()).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command send message failure sets failure state`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.SendPlainText("A_MESSAGE") },
|
||||||
|
proceedSendMessageResult = { _, _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||||
|
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||||
|
// Clear the error
|
||||||
|
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command admin proceeds and resets state on success`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||||
|
proceedAdminResult = { _ -> Result.success(Unit) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||||
|
val successState = awaitItem()
|
||||||
|
// After success, state should be Uninitialized
|
||||||
|
assertThat(successState.slashCommandAction.isUninitialized()).isTrue()
|
||||||
|
assertThat(successState.textEditorState.messageHtml()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - slash command admin proceeds and emit failure on error`() = runTest {
|
||||||
|
val presenter = createPresenter(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.BanUser(A_USER_ID, null) },
|
||||||
|
proceedAdminResult = { _ -> Result.failure(Exception(A_FAILURE_REASON)) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitFirstItem()
|
||||||
|
initialState.textEditorState.setHtml(A_MESSAGE)
|
||||||
|
initialState.eventSink(MessageComposerEvent.SendMessage)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.slashCommandAction.isLoading()).isTrue()
|
||||||
|
val failureState = awaitItem()
|
||||||
|
assertThat(failureState.slashCommandAction.isFailure()).isTrue()
|
||||||
|
assertThat(failureState.slashCommandAction.errorOrNull()?.message).isEqualTo(A_FAILURE_REASON)
|
||||||
|
// Clear error
|
||||||
|
failureState.eventSink(MessageComposerEvent.ClearSlashError)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.slashCommandAction.isUninitialized()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createPresenter(
|
||||||
|
room: JoinedRoom = FakeJoinedRoom(
|
||||||
|
typingNoticeResult = { Result.success(Unit) }
|
||||||
|
),
|
||||||
|
timeline: Timeline = room.liveTimeline,
|
||||||
|
navigator: MessagesNavigator = FakeMessagesNavigator(),
|
||||||
|
pickerProvider: PickerProvider = this@MessageComposerPresenterSlashCommandTest.pickerProvider,
|
||||||
|
locationService: LocationService = FakeLocationService(true),
|
||||||
|
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
|
||||||
|
mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterSlashCommandTest.mediaPreProcessor,
|
||||||
|
snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterSlashCommandTest.snackbarDispatcher,
|
||||||
|
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
|
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||||
|
mentionSpanProvider: MentionSpanProvider = MentionSpanProvider(
|
||||||
|
permalinkParser = permalinkParser,
|
||||||
|
mentionSpanFormatter = FakeMentionSpanFormatter(),
|
||||||
|
mentionSpanTheme = MentionSpanTheme(A_USER_ID)
|
||||||
|
),
|
||||||
|
textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(),
|
||||||
|
isRichTextEditorEnabled: Boolean = true,
|
||||||
|
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||||
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
|
isInThread: Boolean = false,
|
||||||
|
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||||
|
) = MessageComposerPresenter(
|
||||||
|
navigator = navigator,
|
||||||
|
sessionCoroutineScope = this,
|
||||||
|
isInThread = isInThread,
|
||||||
|
room = room,
|
||||||
|
mediaPickerProvider = pickerProvider,
|
||||||
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
|
localMediaFactory = localMediaFactory,
|
||||||
|
mediaSenderFactory = MediaSenderFactory { timelineMode ->
|
||||||
|
DefaultMediaSender(
|
||||||
|
preProcessor = mediaPreProcessor,
|
||||||
|
room = room,
|
||||||
|
timelineMode = timelineMode,
|
||||||
|
mediaOptimizationConfigProvider = {
|
||||||
|
MediaOptimizationConfig(
|
||||||
|
compressImages = true,
|
||||||
|
videoCompressionPreset = VideoCompressionPreset.STANDARD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarDispatcher = snackbarDispatcher,
|
||||||
|
analyticsService = analyticsService,
|
||||||
|
locationService = locationService,
|
||||||
|
messageComposerContext = DefaultMessageComposerContext(),
|
||||||
|
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||||
|
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
|
||||||
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||||
|
permalinkParser = permalinkParser,
|
||||||
|
permalinkBuilder = permalinkBuilder,
|
||||||
|
timelineController = TimelineController(room, timeline),
|
||||||
|
draftService = draftService,
|
||||||
|
mentionSpanProvider = mentionSpanProvider,
|
||||||
|
pillificationHelper = textPillificationHelper,
|
||||||
|
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||||
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
|
notificationConversationService = notificationConversationService,
|
||||||
|
slashCommandService = slashCommandService,
|
||||||
|
).apply {
|
||||||
|
isTesting = true
|
||||||
|
showTextFormatting = isRichTextEditorEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||||
|
skipItems(1)
|
||||||
|
return awaitItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
|
|||||||
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
|
||||||
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
|
||||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
@@ -46,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
|||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
|
||||||
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||||
@@ -89,6 +91,9 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
|
|||||||
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
|
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
@@ -144,6 +149,7 @@ class MessageComposerPresenterTest {
|
|||||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(initialState.canShareLocation).isTrue()
|
assertThat(initialState.canShareLocation).isTrue()
|
||||||
|
assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,10 +380,13 @@ class MessageComposerPresenterTest {
|
|||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
room = FakeJoinedRoom(
|
room = FakeJoinedRoom(
|
||||||
liveTimeline = FakeTimeline().apply {
|
liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
},
|
},
|
||||||
typingNoticeResult = { Result.success(Unit) }
|
typingNoticeResult = { Result.success(Unit) }
|
||||||
),
|
),
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
@@ -409,10 +418,13 @@ class MessageComposerPresenterTest {
|
|||||||
isRichTextEditorEnabled = false,
|
isRichTextEditorEnabled = false,
|
||||||
room = FakeJoinedRoom(
|
room = FakeJoinedRoom(
|
||||||
liveTimeline = FakeTimeline().apply {
|
liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
},
|
},
|
||||||
typingNoticeResult = { Result.success(Unit) }
|
typingNoticeResult = { Result.success(Unit) }
|
||||||
),
|
),
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
@@ -602,7 +614,7 @@ class MessageComposerPresenterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - reply message`() = runTest {
|
fun `present - reply message`() = runTest {
|
||||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val timeline = FakeTimeline().apply {
|
val timeline = FakeTimeline().apply {
|
||||||
@@ -633,7 +645,7 @@ class MessageComposerPresenterTest {
|
|||||||
|
|
||||||
assert(replyMessageLambda)
|
assert(replyMessageLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false))
|
.with(any(), value(A_REPLY), value(A_REPLY), any(), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||||
|
|
||||||
assertThat(analyticsService.capturedEvents).containsExactly(
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
Composer(
|
Composer(
|
||||||
@@ -967,7 +979,12 @@ class MessageComposerPresenterTest {
|
|||||||
)
|
)
|
||||||
givenRoomInfo(aRoomInfo(isDirect = false))
|
givenRoomInfo(aRoomInfo(isDirect = false))
|
||||||
}
|
}
|
||||||
val presenter = createPresenter(room)
|
val presenter = createPresenter(
|
||||||
|
room = room,
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
getSuggestionsResult = { _, _ -> emptyList() },
|
||||||
|
),
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
@@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest {
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `present - send messages with intentional mentions`() = runTest {
|
fun `present - send messages with intentional mentions`() = runTest {
|
||||||
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
|
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean, _: MsgType ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
|
val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val timeline = FakeTimeline().apply {
|
val timeline = FakeTimeline().apply {
|
||||||
@@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest {
|
|||||||
liveTimeline = timeline,
|
liveTimeline = timeline,
|
||||||
typingNoticeResult = { Result.success(Unit) }
|
typingNoticeResult = { Result.success(Unit) }
|
||||||
)
|
)
|
||||||
val presenter = createPresenter(room = room)
|
val presenter = createPresenter(
|
||||||
|
room = room,
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
parseResult = { _, _, _ -> SlashCommand.NotACommand }
|
||||||
|
),
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
|
|
||||||
@@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
sendMessageResult.assertions().isCalledOnce()
|
sendMessageResult.assertions().isCalledOnce()
|
||||||
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))))
|
.with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID))), value(MsgType.MSG_TYPE_TEXT), value(false))
|
||||||
|
|
||||||
// Check intentional mentions on reply sent
|
// Check intentional mentions on reply sent
|
||||||
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
||||||
@@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest {
|
|||||||
|
|
||||||
assert(replyMessageLambda)
|
assert(replyMessageLambda)
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false))
|
.with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false), value(MsgType.MSG_TYPE_TEXT))
|
||||||
|
|
||||||
// Check intentional mentions on edit message
|
// Check intentional mentions on edit message
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
@@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest {
|
|||||||
isRichTextEditorEnabled: Boolean = true,
|
isRichTextEditorEnabled: Boolean = true,
|
||||||
draftService: ComposerDraftService = FakeComposerDraftService(),
|
draftService: ComposerDraftService = FakeComposerDraftService(),
|
||||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||||
|
isInThread: Boolean = false,
|
||||||
|
slashCommandService: SlashCommandService = FakeSlashCommandService(),
|
||||||
) = MessageComposerPresenter(
|
) = MessageComposerPresenter(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
sessionCoroutineScope = this,
|
sessionCoroutineScope = this,
|
||||||
|
isInThread = isInThread,
|
||||||
room = room,
|
room = room,
|
||||||
mediaPickerProvider = pickerProvider,
|
mediaPickerProvider = pickerProvider,
|
||||||
sessionPreferencesStore = sessionPreferencesStore,
|
sessionPreferencesStore = sessionPreferencesStore,
|
||||||
@@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest {
|
|||||||
draftService = draftService,
|
draftService = draftService,
|
||||||
mentionSpanProvider = mentionSpanProvider,
|
mentionSpanProvider = mentionSpanProvider,
|
||||||
pillificationHelper = textPillificationHelper,
|
pillificationHelper = textPillificationHelper,
|
||||||
suggestionsProcessor = SuggestionsProcessor(),
|
suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService),
|
||||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||||
notificationConversationService = notificationConversationService,
|
notificationConversationService = notificationConversationService,
|
||||||
|
slashCommandService = slashCommandService,
|
||||||
).apply {
|
).apply {
|
||||||
isTesting = true
|
isTesting = true
|
||||||
showTextFormatting = isRichTextEditorEnabled
|
showTextFormatting = isRichTextEditorEnabled
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||||
|
import io.element.android.libraries.slashcommands.test.FakeSlashCommandService
|
||||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||||
@@ -27,10 +29,13 @@ import org.junit.Test
|
|||||||
class SuggestionsProcessorTest {
|
class SuggestionsProcessorTest {
|
||||||
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
|
private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text)
|
||||||
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
|
private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text)
|
||||||
private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "")
|
|
||||||
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
|
private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "")
|
||||||
|
|
||||||
private val suggestionsProcessor = SuggestionsProcessor()
|
private val suggestionsProcessor = SuggestionsProcessor(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
getSuggestionsResult = { _, _ -> emptyList() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `processing null suggestion will return empty suggestion`() = runTest {
|
fun `processing null suggestion will return empty suggestion`() = runTest {
|
||||||
@@ -40,18 +45,59 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `processing Command will return empty suggestion`() = runTest {
|
fun `processing Command will return suggestions from the slash service`() = runTest {
|
||||||
val result = suggestionsProcessor.process(
|
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||||
suggestion = aCommandSuggestion,
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
getSuggestionsResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
SlashCommandSuggestion(
|
||||||
|
command = "aCommand",
|
||||||
|
parameters = null,
|
||||||
|
description = "A description",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = suggestionsProcessorWithCommand.process(
|
||||||
|
suggestion = Suggestion(0, 1, SuggestionType.Command, ""),
|
||||||
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
|
)
|
||||||
|
assertThat(result).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processing Command will return empty list if start of suggestion is not 0`() = runTest {
|
||||||
|
val suggestionsProcessorWithCommand = SuggestionsProcessor(
|
||||||
|
slashCommandService = FakeSlashCommandService(
|
||||||
|
getSuggestionsResult = { _, _ ->
|
||||||
|
listOf(
|
||||||
|
SlashCommandSuggestion(
|
||||||
|
command = "aCommand",
|
||||||
|
parameters = null,
|
||||||
|
description = "A description",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = suggestionsProcessorWithCommand.process(
|
||||||
|
suggestion = Suggestion(1, 2, SuggestionType.Command, ""),
|
||||||
|
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())),
|
||||||
|
roomAliasSuggestions = emptyList(),
|
||||||
|
currentUserId = A_USER_ID,
|
||||||
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -64,6 +110,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -76,6 +123,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -88,6 +136,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -100,6 +149,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -120,6 +170,7 @@ class SuggestionsProcessorTest {
|
|||||||
),
|
),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -149,6 +200,7 @@ class SuggestionsProcessorTest {
|
|||||||
),
|
),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -178,6 +230,7 @@ class SuggestionsProcessorTest {
|
|||||||
),
|
),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -198,6 +251,7 @@ class SuggestionsProcessorTest {
|
|||||||
),
|
),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -227,6 +281,7 @@ class SuggestionsProcessorTest {
|
|||||||
),
|
),
|
||||||
currentUserId = A_USER_ID,
|
currentUserId = A_USER_ID,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -240,6 +295,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -257,6 +313,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = UserId("@alice:server.org"),
|
currentUserId = UserId("@alice:server.org"),
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -270,6 +327,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -283,6 +341,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEmpty()
|
assertThat(result).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -296,6 +355,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -313,6 +373,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { true },
|
canSendRoomMention = { true },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -331,6 +392,7 @@ class SuggestionsProcessorTest {
|
|||||||
roomAliasSuggestions = emptyList(),
|
roomAliasSuggestions = emptyList(),
|
||||||
currentUserId = A_USER_ID_2,
|
currentUserId = A_USER_ID_2,
|
||||||
canSendRoomMention = { false },
|
canSendRoomMention = { false },
|
||||||
|
isInThread = false,
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
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.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
||||||
@@ -154,10 +155,10 @@ class TimelineControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
|
fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest {
|
||||||
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention> ->
|
val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention> ->
|
val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val liveTimeline = FakeTimeline(name = "live").apply {
|
val liveTimeline = FakeTimeline(name = "live").apply {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
|||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object NotificationTroubleshoot : InitialTarget
|
data object NotificationTroubleshoot : InitialTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object DeveloperSettings : InitialTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
|
|||||||
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
|
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
|
||||||
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
|
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
|
||||||
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
|
PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications
|
||||||
|
PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,11 @@ class PreferencesFlowNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
|
if (backstack.canPop()) {
|
||||||
backstack.pop()
|
backstack.pop()
|
||||||
|
} else {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
|||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToGlobalNotificationSettings()
|
fun navigateToGlobalNotificationSettings()
|
||||||
|
fun navigateToDeveloperSettings()
|
||||||
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
|
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
|
||||||
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
|
||||||
fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)
|
fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean)
|
||||||
|
|||||||
@@ -388,6 +388,10 @@ class RoomDetailsFlowNode(
|
|||||||
override fun navigateToRoom(roomId: RoomId) {
|
override fun navigateToRoom(roomId: RoomId) {
|
||||||
callback.navigateToRoom(roomId, emptyList())
|
callback.navigateToRoom(roomId, emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToDeveloperSettings() {
|
||||||
|
callback.navigateToDeveloperSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return messagesEntryPoint.createNode(
|
return messagesEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class DefaultRoomDetailsEntryPointTest {
|
|||||||
}
|
}
|
||||||
val callback = object : RoomDetailsEntryPoint.Callback {
|
val callback = object : RoomDetailsEntryPoint.Callback {
|
||||||
override fun navigateToGlobalNotificationSettings() = lambdaError()
|
override fun navigateToGlobalNotificationSettings() = lambdaError()
|
||||||
|
override fun navigateToDeveloperSettings() = lambdaError()
|
||||||
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
|
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
|
||||||
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
|
||||||
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError()
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class SharePresenterTest {
|
|||||||
fun `present - on room selected ok`() = runTest {
|
fun `present - on room selected ok`() = runTest {
|
||||||
val joinedRoom = FakeJoinedRoom(
|
val joinedRoom = FakeJoinedRoom(
|
||||||
liveTimeline = FakeTimeline().apply {
|
liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
@@ -103,7 +103,7 @@ class SharePresenterTest {
|
|||||||
fun `present - send text ok`() = runTest {
|
fun `present - send text ok`() = runTest {
|
||||||
val joinedRoom = FakeJoinedRoom(
|
val joinedRoom = FakeJoinedRoom(
|
||||||
liveTimeline = FakeTimeline().apply {
|
liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
|
sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
|||||||
@@ -169,4 +169,11 @@ enum class FeatureFlags(
|
|||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
SlashCommand(
|
||||||
|
key = "feature.slash_command",
|
||||||
|
title = "Parse slash commands in the message composer",
|
||||||
|
description = "Allow parsing slash commands in the message composer and perform action.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
|
|||||||
is Id -> roomId.value
|
is Id -> roomId.value
|
||||||
is Alias -> roomAlias.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)
|
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)
|
||||||
|
|||||||
@@ -17,3 +17,18 @@ interface MxcTools {
|
|||||||
*/
|
*/
|
||||||
fun mxcUri2FilePath(mxcUri: String): String?
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
|
|||||||
body: String,
|
body: String,
|
||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
|
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||||
|
asPlainText: Boolean = false,
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
suspend fun editMessage(
|
suspend fun editMessage(
|
||||||
@@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
|
|||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
fromNotification: Boolean = false,
|
fromNotification: Boolean = false,
|
||||||
|
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
suspend fun sendImage(
|
suspend fun sendImage(
|
||||||
|
|||||||
@@ -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.isDm
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
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.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.ReceiptType
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||||
@@ -271,8 +272,16 @@ class RustTimeline(
|
|||||||
body: String,
|
body: String,
|
||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
|
msgType: MsgType,
|
||||||
|
asPlainText: Boolean,
|
||||||
): Result<Unit> = withContext(dispatcher) {
|
): 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> {
|
runCatchingExceptions<Unit> {
|
||||||
inner.send(content)
|
inner.send(content)
|
||||||
}
|
}
|
||||||
@@ -337,9 +346,15 @@ class RustTimeline(
|
|||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
fromNotification: Boolean,
|
fromNotification: Boolean,
|
||||||
|
msgType: MsgType,
|
||||||
): Result<Unit> = withContext(dispatcher) {
|
): Result<Unit> = withContext(dispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
|
val msg = MessageEventContent.from(
|
||||||
|
body = body,
|
||||||
|
htmlBody = htmlBody,
|
||||||
|
intentionalMentions = intentionalMentions,
|
||||||
|
msgType = msgType,
|
||||||
|
)
|
||||||
inner.sendReply(
|
inner.sendReply(
|
||||||
msg = msg,
|
msg = msg,
|
||||||
eventId = repliedToEventId.value,
|
eventId = repliedToEventId.value,
|
||||||
|
|||||||
@@ -9,20 +9,54 @@
|
|||||||
package io.element.android.libraries.matrix.impl.util
|
package io.element.android.libraries.matrix.impl.util
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
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 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.RoomMessageEventContentWithoutRelation
|
||||||
|
import org.matrix.rustcomponents.sdk.TextMessageContent
|
||||||
|
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||||
|
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
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.
|
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
|
||||||
*/
|
*/
|
||||||
object MessageEventContent {
|
object MessageEventContent {
|
||||||
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
|
fun from(
|
||||||
return if (htmlBody != null) {
|
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)
|
messageEventContentFromHtml(body, htmlBody)
|
||||||
|
}
|
||||||
|
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
|
||||||
|
messageEventContentFromMarkdownAsEmote(body)
|
||||||
} else {
|
} else {
|
||||||
messageEventContentFromMarkdown(body)
|
messageEventContentFromMarkdown(body)
|
||||||
}.withMentions(intentionalMentions.map())
|
}
|
||||||
|
}
|
||||||
|
.withMentions(intentionalMentions.map())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.IntentionalMention
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
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.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.ReceiptType
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||||
@@ -64,7 +65,9 @@ class FakeTimeline(
|
|||||||
body: String,
|
body: String,
|
||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
) -> Result<Unit> = { _, _, _ ->
|
msgType: MsgType,
|
||||||
|
asPlainText: Boolean,
|
||||||
|
) -> Result<Unit> = { _, _, _, _, _ ->
|
||||||
lambdaError()
|
lambdaError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +79,10 @@ class FakeTimeline(
|
|||||||
body: String,
|
body: String,
|
||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
|
msgType: MsgType,
|
||||||
|
asPlainText: Boolean,
|
||||||
): Result<Unit> = simulateLongTask {
|
): Result<Unit> = simulateLongTask {
|
||||||
sendMessageLambda(body, htmlBody, intentionalMentions)
|
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
|
||||||
}
|
}
|
||||||
|
|
||||||
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
|
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
|
||||||
@@ -134,7 +139,8 @@ class FakeTimeline(
|
|||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
fromNotification: Boolean,
|
fromNotification: Boolean,
|
||||||
) -> Result<Unit> = { _, _, _, _, _ ->
|
msgType: MsgType,
|
||||||
|
) -> Result<Unit> = { _, _, _, _, _, _ ->
|
||||||
lambdaError()
|
lambdaError()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +150,14 @@ class FakeTimeline(
|
|||||||
htmlBody: String?,
|
htmlBody: String?,
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
fromNotification: Boolean,
|
fromNotification: Boolean,
|
||||||
|
msgType: MsgType,
|
||||||
): Result<Unit> = replyMessageLambda(
|
): Result<Unit> = replyMessageLambda(
|
||||||
repliedToEventId,
|
repliedToEventId,
|
||||||
body,
|
body,
|
||||||
htmlBody,
|
htmlBody,
|
||||||
intentionalMentions,
|
intentionalMentions,
|
||||||
fromNotification,
|
fromNotification,
|
||||||
|
msgType,
|
||||||
)
|
)
|
||||||
|
|
||||||
var sendImageLambda: (
|
var sendImageLambda: (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||||
|
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.ReceiptType
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
@@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Test send reply`() = runTest {
|
fun `Test send reply`() = runTest {
|
||||||
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
val replyMessage =
|
val replyMessage =
|
||||||
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
|
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
|
||||||
val liveTimeline = FakeTimeline().apply {
|
val liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = sendMessage
|
sendMessageLambda = sendMessage
|
||||||
replyMessageLambda = replyMessage
|
replyMessageLambda = replyMessage
|
||||||
@@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
sendMessage.assertions()
|
sendMessage.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
|
.with(
|
||||||
|
value(A_MESSAGE),
|
||||||
|
value(null),
|
||||||
|
value(emptyList<IntentionalMention>()),
|
||||||
|
value(MsgType.MSG_TYPE_TEXT),
|
||||||
|
value(false),
|
||||||
|
)
|
||||||
onNotifiableEventsReceivedResult.assertions()
|
onNotifiableEventsReceivedResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
replyMessage.assertions()
|
replyMessage.assertions()
|
||||||
@@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Test send reply blank message`() = runTest {
|
fun `Test send reply blank message`() = runTest {
|
||||||
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
val liveTimeline = FakeTimeline().apply {
|
val liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = sendMessage
|
sendMessageLambda = sendMessage
|
||||||
}
|
}
|
||||||
@@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Test send reply to thread`() = runTest {
|
fun `Test send reply to thread`() = runTest {
|
||||||
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
|
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
|
||||||
val replyMessage =
|
val replyMessage =
|
||||||
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
|
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
|
||||||
val liveTimeline = FakeTimeline().apply {
|
val liveTimeline = FakeTimeline().apply {
|
||||||
sendMessageLambda = sendMessage
|
sendMessageLambda = sendMessage
|
||||||
replyMessageLambda = replyMessage
|
replyMessageLambda = replyMessage
|
||||||
@@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
|
|||||||
value(A_MESSAGE),
|
value(A_MESSAGE),
|
||||||
value(null),
|
value(null),
|
||||||
value(emptyList<IntentionalMention>()),
|
value(emptyList<IntentionalMention>()),
|
||||||
value(true)
|
value(true),
|
||||||
|
value(MsgType.MSG_TYPE_TEXT),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
libraries/slashcommands/api/build.gradle.kts
Normal file
17
libraries/slashcommands/api/build.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.slashcommands.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.api
|
||||||
|
|
||||||
|
enum class ChatEffect {
|
||||||
|
CONFETTI,
|
||||||
|
SNOWFALL
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.api
|
||||||
|
|
||||||
|
enum class MessagePrefix {
|
||||||
|
Shrug,
|
||||||
|
TableFlip,
|
||||||
|
Unflip,
|
||||||
|
Lenny,
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represent a slash command.
|
||||||
|
*/
|
||||||
|
sealed interface SlashCommand {
|
||||||
|
// This is not a Slash command
|
||||||
|
data object NotACommand : SlashCommand
|
||||||
|
|
||||||
|
// Slash command types:
|
||||||
|
sealed interface Error : SlashCommand
|
||||||
|
sealed interface SlashCommandSendMessage : SlashCommand
|
||||||
|
sealed interface SlashCommandAdmin : SlashCommand
|
||||||
|
sealed interface SlashCommandNavigation : SlashCommand
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
data class ErrorEmptySlashCommand(val message: String) : Error
|
||||||
|
data class ErrorCommandNotSupportedInThreads(val message: String) : Error
|
||||||
|
|
||||||
|
// Unknown/Unsupported slash command
|
||||||
|
data class ErrorUnknownSlashCommand(val message: String) : Error
|
||||||
|
|
||||||
|
// A slash command is detected, but there is an error
|
||||||
|
data class ErrorSyntax(val message: String) : Error
|
||||||
|
|
||||||
|
// Valid commands:
|
||||||
|
data class SendPlainText(val message: CharSequence) : SlashCommandSendMessage
|
||||||
|
data class SendEmote(val message: CharSequence) : SlashCommandSendMessage
|
||||||
|
data class SendRainbow(val message: CharSequence) : SlashCommandSendMessage
|
||||||
|
data class SendRainbowEmote(val message: CharSequence) : SlashCommandSendMessage
|
||||||
|
data class BanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
|
||||||
|
data class UnbanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
|
||||||
|
data class IgnoreUser(val userId: UserId) : SlashCommandAdmin
|
||||||
|
data class UnignoreUser(val userId: UserId) : SlashCommandAdmin
|
||||||
|
data class SetUserPowerLevel(val userId: UserId, val powerLevel: Int?) : SlashCommandAdmin
|
||||||
|
data class ChangeRoomName(val name: String) : SlashCommandAdmin
|
||||||
|
data class Invite(val userId: UserId, val reason: String?) : SlashCommandAdmin
|
||||||
|
data class JoinRoom(val roomIdOrAlias: RoomIdOrAlias, val reason: String?) : SlashCommandAdmin
|
||||||
|
data class ChangeTopic(val topic: String) : SlashCommandAdmin
|
||||||
|
data class RemoveUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
|
||||||
|
data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin
|
||||||
|
data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin
|
||||||
|
data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin
|
||||||
|
data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin
|
||||||
|
data class SendSpoiler(val message: String) : SlashCommandSendMessage
|
||||||
|
data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage
|
||||||
|
data object DiscardSession : SlashCommandAdmin
|
||||||
|
data class SendChatEffect(val chatEffect: ChatEffect, val message: String) : SlashCommandSendMessage
|
||||||
|
data object LeaveRoom : SlashCommandAdmin
|
||||||
|
data class UpgradeRoom(val newVersion: String) : SlashCommandAdmin
|
||||||
|
|
||||||
|
data object DevTools : SlashCommandNavigation
|
||||||
|
data class ShowUser(val userId: UserId) : SlashCommandNavigation
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SlashCommand.Error.message() = when (this) {
|
||||||
|
is SlashCommand.ErrorEmptySlashCommand -> message
|
||||||
|
is SlashCommand.ErrorCommandNotSupportedInThreads -> message
|
||||||
|
is SlashCommand.ErrorUnknownSlashCommand -> message
|
||||||
|
is SlashCommand.ErrorSyntax -> message
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
||||||
|
interface SlashCommandService {
|
||||||
|
suspend fun getSuggestions(
|
||||||
|
text: String,
|
||||||
|
isInThread: Boolean,
|
||||||
|
): List<SlashCommandSuggestion>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the message and return a SlashCommand.
|
||||||
|
*/
|
||||||
|
suspend fun parse(
|
||||||
|
textMessage: CharSequence,
|
||||||
|
formattedMessage: String?,
|
||||||
|
isInThreadTimeline: Boolean,
|
||||||
|
): SlashCommand
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed a SlashCommandSendMessage.
|
||||||
|
*/
|
||||||
|
suspend fun proceedSendMessage(
|
||||||
|
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||||
|
timeline: Timeline,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed a SlashCommandAdmin.
|
||||||
|
*/
|
||||||
|
suspend fun proceedAdmin(
|
||||||
|
slashCommand: SlashCommand.SlashCommandAdmin,
|
||||||
|
): Result<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.api
|
||||||
|
|
||||||
|
data class SlashCommandSuggestion(
|
||||||
|
val command: String,
|
||||||
|
val parameters: String?,
|
||||||
|
val description: String,
|
||||||
|
)
|
||||||
35
libraries/slashcommands/impl/build.gradle.kts
Normal file
35
libraries/slashcommands/impl/build.gradle.kts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import extension.setupDependencyInjection
|
||||||
|
import extension.testCommonDependencies
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.slashcommands.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDependencyInjection()
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
api(projects.libraries.slashcommands.api)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.libraries.featureflag.api)
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
|
|
||||||
|
testCommonDependencies(libs)
|
||||||
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.services.toolbox.test)
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the command line operations.
|
||||||
|
* The user can write these messages to perform some actions.
|
||||||
|
* The list will be displayed in this order.
|
||||||
|
*/
|
||||||
|
enum class Command(
|
||||||
|
val command: String,
|
||||||
|
val aliases: List<String>? = null,
|
||||||
|
val parameters: String? = null,
|
||||||
|
@StringRes val description: Int,
|
||||||
|
val isAllowedInThread: Boolean = true,
|
||||||
|
val isSupported: Boolean = true,
|
||||||
|
val isDevCommand: Boolean = false,
|
||||||
|
) {
|
||||||
|
CRASH_APP(
|
||||||
|
command = "/crash",
|
||||||
|
description = R.string.slash_command_description_crash_application,
|
||||||
|
isDevCommand = true,
|
||||||
|
),
|
||||||
|
EMOTE(
|
||||||
|
command = "/me",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_emote,
|
||||||
|
),
|
||||||
|
BAN_USER(
|
||||||
|
command = "/ban",
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = R.string.slash_command_description_ban_user,
|
||||||
|
),
|
||||||
|
UNBAN_USER(
|
||||||
|
command = "/unban",
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = R.string.slash_command_description_unban_user,
|
||||||
|
),
|
||||||
|
IGNORE_USER(
|
||||||
|
command = "/ignore",
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = R.string.slash_command_description_ignore_user,
|
||||||
|
),
|
||||||
|
UNIGNORE_USER(
|
||||||
|
command = "/unignore",
|
||||||
|
parameters = "<user-id>",
|
||||||
|
description = R.string.slash_command_description_unignore_user,
|
||||||
|
),
|
||||||
|
SET_USER_POWER_LEVEL(
|
||||||
|
command = "/op",
|
||||||
|
parameters = "<user-id> [<power-level>]",
|
||||||
|
description = R.string.slash_command_description_op_user,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
RESET_USER_POWER_LEVEL(
|
||||||
|
command = "/deop",
|
||||||
|
parameters = "<user-id>",
|
||||||
|
description = R.string.slash_command_description_deop_user,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
ROOM_NAME(
|
||||||
|
command = "/roomname",
|
||||||
|
parameters = "<name>",
|
||||||
|
description = R.string.slash_command_description_room_name,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
),
|
||||||
|
INVITE(
|
||||||
|
command = "/invite",
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = R.string.slash_command_description_invite_user,
|
||||||
|
),
|
||||||
|
JOIN_ROOM(
|
||||||
|
command = "/join",
|
||||||
|
aliases = listOf("/j", "/goto"),
|
||||||
|
parameters = "<room-address> [reason]",
|
||||||
|
description = R.string.slash_command_description_join_room,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
TOPIC(
|
||||||
|
command = "/topic",
|
||||||
|
parameters = "<topic>",
|
||||||
|
description = R.string.slash_command_description_topic,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
),
|
||||||
|
REMOVE_USER(
|
||||||
|
command = "/remove",
|
||||||
|
aliases = listOf("/kick"),
|
||||||
|
parameters = "<user-id> [reason]",
|
||||||
|
description = R.string.slash_command_description_remove_user,
|
||||||
|
),
|
||||||
|
CHANGE_DISPLAY_NAME(
|
||||||
|
command = "/nick",
|
||||||
|
parameters = "<display-name>",
|
||||||
|
description = R.string.slash_command_description_nick,
|
||||||
|
),
|
||||||
|
CHANGE_DISPLAY_NAME_FOR_ROOM(
|
||||||
|
command = "/myroomnick",
|
||||||
|
aliases = listOf("/roomnick"),
|
||||||
|
parameters = "<display-name>",
|
||||||
|
description = R.string.slash_command_description_nick_for_room,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
ROOM_AVATAR(
|
||||||
|
command = "/roomavatar",
|
||||||
|
parameters = "<mxc_url>",
|
||||||
|
description = R.string.slash_command_description_room_avatar,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
// Dev command since user has to know the mxc url
|
||||||
|
isDevCommand = true,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
CHANGE_AVATAR_FOR_ROOM(
|
||||||
|
command = "/myroomavatar",
|
||||||
|
parameters = "<mxc_url>",
|
||||||
|
description = R.string.slash_command_description_avatar_for_room,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
// Dev command since user has to know the mxc url
|
||||||
|
isDevCommand = true,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
RAINBOW(
|
||||||
|
command = "/rainbow",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_rainbow,
|
||||||
|
),
|
||||||
|
RAINBOW_EMOTE(
|
||||||
|
command = "/rainbowme",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_rainbow_emote,
|
||||||
|
),
|
||||||
|
DEVTOOLS(
|
||||||
|
command = "/devtools",
|
||||||
|
description = R.string.slash_command_description_devtools,
|
||||||
|
isDevCommand = true,
|
||||||
|
),
|
||||||
|
SPOILER(
|
||||||
|
command = "/spoiler",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_spoiler,
|
||||||
|
),
|
||||||
|
SHRUG(
|
||||||
|
command = "/shrug",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_shrug,
|
||||||
|
),
|
||||||
|
LENNY(
|
||||||
|
command = "/lenny",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_lenny,
|
||||||
|
),
|
||||||
|
PLAIN(
|
||||||
|
command = "/plain",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_plain,
|
||||||
|
),
|
||||||
|
WHOIS(
|
||||||
|
command = "/whois",
|
||||||
|
parameters = "<user-id>",
|
||||||
|
description = R.string.slash_command_description_whois,
|
||||||
|
),
|
||||||
|
DISCARD_SESSION(
|
||||||
|
command = "/discardsession",
|
||||||
|
description = R.string.slash_command_description_discard_session,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
CONFETTI(
|
||||||
|
command = "/confetti",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_confetti,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
SNOWFALL(
|
||||||
|
command = "/snowfall",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_snow,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
LEAVE_ROOM(
|
||||||
|
command = "/leave",
|
||||||
|
aliases = listOf("/part"),
|
||||||
|
description = R.string.slash_command_description_leave_room,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isDevCommand = true,
|
||||||
|
),
|
||||||
|
UPGRADE_ROOM(
|
||||||
|
command = "/upgraderoom",
|
||||||
|
parameters = "newVersion",
|
||||||
|
description = R.string.slash_command_description_upgrade_room,
|
||||||
|
isAllowedInThread = false,
|
||||||
|
isDevCommand = true,
|
||||||
|
isSupported = false,
|
||||||
|
),
|
||||||
|
TABLE_FLIP(
|
||||||
|
command = "/tableflip",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_table_flip,
|
||||||
|
),
|
||||||
|
UNFLIP(
|
||||||
|
command = "/unflip",
|
||||||
|
parameters = "<message>",
|
||||||
|
description = R.string.slash_command_description_unflip,
|
||||||
|
);
|
||||||
|
|
||||||
|
val allAliases = listOf(command) + aliases.orEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the input command matches any of the command aliases, ignoring case.
|
||||||
|
* Do not exclude not supported commands so that user can discover that the command is not supported.
|
||||||
|
* Used for whole command parsing.
|
||||||
|
*/
|
||||||
|
fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the input is a prefix of any of the command aliases, ignoring the first character (the slash), and excluding not supported command.
|
||||||
|
* Used for suggestions.
|
||||||
|
*/
|
||||||
|
fun startsWith(input: CharSequence) = isSupported &&
|
||||||
|
allAliases.any { it.startsWith(input, 1, true) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class CommandExecutor(
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
|
private val joinedRoom: JoinedRoom,
|
||||||
|
private val rainbowGenerator: RainbowGenerator,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
) {
|
||||||
|
suspend fun proceedSendMessage(
|
||||||
|
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||||
|
timeline: Timeline,
|
||||||
|
): Result<Unit> {
|
||||||
|
return when (slashCommand) {
|
||||||
|
is SlashCommand.SendChatEffect -> sendChatEffect()
|
||||||
|
is SlashCommand.SendEmote -> sendEmote(slashCommand, timeline)
|
||||||
|
is SlashCommand.SendWithPrefix -> sendPrefixedMessage(slashCommand.prefix, slashCommand.message, timeline)
|
||||||
|
is SlashCommand.SendPlainText -> sendPlainText(slashCommand, timeline)
|
||||||
|
is SlashCommand.SendRainbow -> sendRainbow(slashCommand, timeline)
|
||||||
|
is SlashCommand.SendRainbowEmote -> sendRainbowEmote(slashCommand, timeline)
|
||||||
|
is SlashCommand.SendSpoiler -> sendSpoiler(slashCommand, timeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun proceedAdmin(
|
||||||
|
slashCommand: SlashCommand.SlashCommandAdmin,
|
||||||
|
): Result<Unit> {
|
||||||
|
return when (slashCommand) {
|
||||||
|
is SlashCommand.BanUser -> banUser(slashCommand)
|
||||||
|
is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
|
||||||
|
is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
|
||||||
|
is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
|
||||||
|
is SlashCommand.ChangeRoomAvatar -> changeRoomAvatar()
|
||||||
|
is SlashCommand.ChangeRoomName -> changeRoomName(slashCommand)
|
||||||
|
is SlashCommand.ChangeTopic -> changeTopic(slashCommand)
|
||||||
|
is SlashCommand.DiscardSession -> discardSession()
|
||||||
|
is SlashCommand.IgnoreUser -> ignoreUser(slashCommand)
|
||||||
|
is SlashCommand.Invite -> invite(slashCommand)
|
||||||
|
is SlashCommand.JoinRoom -> joinRoom(slashCommand)
|
||||||
|
is SlashCommand.LeaveRoom -> leaveRoom(joinedRoom)
|
||||||
|
is SlashCommand.RemoveUser -> removeUser(slashCommand)
|
||||||
|
is SlashCommand.SetUserPowerLevel -> setUserPowerLevel()
|
||||||
|
is SlashCommand.UnbanUser -> unbanUser(slashCommand)
|
||||||
|
is SlashCommand.UnignoreUser -> unignoreUser(slashCommand)
|
||||||
|
is SlashCommand.UpgradeRoom -> upgradeRoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upgradeRoom(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result<Unit> {
|
||||||
|
return matrixClient.unignoreUser(slashCommand.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result<Unit> {
|
||||||
|
return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUserPowerLevel(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result<Unit> {
|
||||||
|
val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})"
|
||||||
|
val formattedText = "<span data-mx-spoiler>${slashCommand.message}</span>"
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = text,
|
||||||
|
htmlBody = formattedText,
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result<Unit> {
|
||||||
|
val message = slashCommand.message.toString()
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = message,
|
||||||
|
htmlBody = rainbowGenerator.generate(message),
|
||||||
|
msgType = MsgType.MSG_TYPE_EMOTE,
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendRainbow(slashCommand: SlashCommand.SendRainbow, timeline: Timeline): Result<Unit> {
|
||||||
|
val message = slashCommand.message.toString()
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = message,
|
||||||
|
htmlBody = rainbowGenerator.generate(message),
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendPlainText(slashCommand: SlashCommand.SendPlainText, timeline: Timeline): Result<Unit> {
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = slashCommand.message.toString(),
|
||||||
|
htmlBody = null,
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
asPlainText = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result<Unit> {
|
||||||
|
val message = slashCommand.message.toString()
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = message,
|
||||||
|
htmlBody = null,
|
||||||
|
msgType = MsgType.MSG_TYPE_EMOTE,
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendChatEffect(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result<Unit> {
|
||||||
|
return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun leaveRoom(
|
||||||
|
room: JoinedRoom,
|
||||||
|
): Result<Unit> {
|
||||||
|
return room.leave()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result<Unit> {
|
||||||
|
return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList())
|
||||||
|
.map {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun invite(slashCommand: SlashCommand.Invite): Result<Unit> {
|
||||||
|
return joinedRoom.inviteUserById(slashCommand.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result<Unit> {
|
||||||
|
return matrixClient.ignoreUser(slashCommand.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discardSession(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result<Unit> {
|
||||||
|
return joinedRoom.setTopic(slashCommand.topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result<Unit> {
|
||||||
|
return joinedRoom.setName(slashCommand.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeRoomAvatar(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeDisplayNameForRoom(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result<Unit> {
|
||||||
|
return matrixClient.setDisplayName(slashCommand.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeAvatarForRoom(): Result<Unit> {
|
||||||
|
return Result.failure(Exception("Not yet implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result<Unit> {
|
||||||
|
return joinedRoom.banUser(slashCommand.userId, slashCommand.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendPrefixedMessage(
|
||||||
|
prefix: MessagePrefix,
|
||||||
|
message: CharSequence,
|
||||||
|
timeline: Timeline,
|
||||||
|
): Result<Unit> {
|
||||||
|
val sequence = buildString {
|
||||||
|
append(prefix.toMarkdown())
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
append(" ")
|
||||||
|
append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timeline.sendMessage(
|
||||||
|
body = sequence,
|
||||||
|
htmlBody = null,
|
||||||
|
intentionalMentions = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MessagePrefix.toMarkdown() = when (this) {
|
||||||
|
MessagePrefix.Shrug -> "¯\\\\_(ツ)\\_/¯"
|
||||||
|
MessagePrefix.TableFlip -> "(╯°□°)╯︵ ┻━┻"
|
||||||
|
MessagePrefix.Unflip -> "┬──┬ ノ( ゜-゜ノ)"
|
||||||
|
MessagePrefix.Lenny -> "( ͡° ͜ʖ ͡°)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.mxc.isMxcUrl
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||||
|
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class CommandParser(
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Convert the text message into a Slash command.
|
||||||
|
*
|
||||||
|
* @param textMessage the text message in plain text
|
||||||
|
* @param formattedMessage the text messaged in HTML format
|
||||||
|
* @param isInThreadTimeline true if the user is currently typing in a thread
|
||||||
|
* @return a parsed slash command (ok or error)
|
||||||
|
*/
|
||||||
|
suspend fun parseSlashCommand(
|
||||||
|
textMessage: CharSequence,
|
||||||
|
formattedMessage: String?,
|
||||||
|
isInThreadTimeline: Boolean,
|
||||||
|
): SlashCommand {
|
||||||
|
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) {
|
||||||
|
return SlashCommand.NotACommand
|
||||||
|
}
|
||||||
|
// check if it has the Slash marker
|
||||||
|
val message = formattedMessage ?: textMessage
|
||||||
|
return if (!message.startsWith("/")) {
|
||||||
|
SlashCommand.NotACommand
|
||||||
|
} else {
|
||||||
|
// "/" only
|
||||||
|
if (message.length == 1) {
|
||||||
|
return SlashCommand.ErrorEmptySlashCommand(
|
||||||
|
stringProvider.getString(R.string.slash_command_unrecognized, "/")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Exclude "//"
|
||||||
|
if ("/" == message.substring(1, 2)) {
|
||||||
|
return SlashCommand.NotACommand
|
||||||
|
}
|
||||||
|
val (messageParts, message) = extractMessage(message.toString())
|
||||||
|
?: return SlashCommand.ErrorEmptySlashCommand(
|
||||||
|
stringProvider.getString(R.string.slash_command_unrecognized, "/")
|
||||||
|
)
|
||||||
|
val slashCommand = messageParts.first()
|
||||||
|
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
|
||||||
|
return SlashCommand.ErrorCommandNotSupportedInThreads(
|
||||||
|
stringProvider.getString(
|
||||||
|
R.string.slash_command_not_supported_in_threads,
|
||||||
|
it.command,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
Command.PLAIN.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.SendPlainText(message = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.PLAIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.ChangeDisplayName(displayName = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.CHANGE_DISPLAY_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.ChangeDisplayNameForRoom(displayName = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.CHANGE_DISPLAY_NAME_FOR_ROOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.ROOM_AVATAR.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 2) {
|
||||||
|
val url = messageParts[1]
|
||||||
|
if (url.isMxcUrl()) {
|
||||||
|
SlashCommand.ChangeRoomAvatar(url)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.ROOM_AVATAR)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.ROOM_AVATAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 2) {
|
||||||
|
val url = messageParts[1]
|
||||||
|
|
||||||
|
if (url.isMxcUrl()) {
|
||||||
|
SlashCommand.ChangeAvatarForRoom(url)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.TOPIC.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.ChangeTopic(topic = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.TOPIC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.EMOTE.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.SendEmote(message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.EMOTE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.RAINBOW.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.SendRainbow(message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.RAINBOW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.RAINBOW_EMOTE.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.SendRainbowEmote(message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.RAINBOW_EMOTE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.JOIN_ROOM.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size >= 2) {
|
||||||
|
val id = messageParts[1]
|
||||||
|
val roomIdOrAlias = RoomIdOrAlias.from(id)
|
||||||
|
if (roomIdOrAlias != null) {
|
||||||
|
SlashCommand.JoinRoom(
|
||||||
|
RoomIdOrAlias.Id(RoomId(id)),
|
||||||
|
trimParts(textMessage, messageParts.take(2))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.JOIN_ROOM)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.JOIN_ROOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.ROOM_NAME.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.ChangeRoomName(name = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.ROOM_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.INVITE.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size >= 2) {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.Invite(
|
||||||
|
userId = userId,
|
||||||
|
reason = trimParts(textMessage, messageParts.take(2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.INVITE)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.INVITE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.REMOVE_USER.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.RemoveUser(
|
||||||
|
userId = userId,
|
||||||
|
reason = trimParts(textMessage, messageParts.take(2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.REMOVE_USER)
|
||||||
|
}
|
||||||
|
Command.BAN_USER.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.BanUser(
|
||||||
|
userId = userId,
|
||||||
|
reason = trimParts(textMessage, messageParts.take(2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.BAN_USER)
|
||||||
|
}
|
||||||
|
Command.UNBAN_USER.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.UnbanUser(
|
||||||
|
userId = userId,
|
||||||
|
reason = trimParts(textMessage, messageParts.take(2))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.UNBAN_USER)
|
||||||
|
}
|
||||||
|
Command.IGNORE_USER.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.IgnoreUser(
|
||||||
|
userId = userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.IGNORE_USER)
|
||||||
|
}
|
||||||
|
Command.UNIGNORE_USER.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.UnignoreUser(
|
||||||
|
userId = userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.UNIGNORE_USER)
|
||||||
|
}
|
||||||
|
Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 3) {
|
||||||
|
val userId = parseUserId(messageParts)
|
||||||
|
if (userId != null) {
|
||||||
|
val powerLevelsAsString = messageParts[2]
|
||||||
|
try {
|
||||||
|
val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString)
|
||||||
|
SlashCommand.SetUserPowerLevel(
|
||||||
|
userId = userId,
|
||||||
|
powerLevel = powerLevelsAsInt
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.SetUserPowerLevel(
|
||||||
|
userId = userId,
|
||||||
|
powerLevel = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.SET_USER_POWER_LEVEL)
|
||||||
|
}
|
||||||
|
Command.DEVTOOLS.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 1) {
|
||||||
|
SlashCommand.DevTools
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.DEVTOOLS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.SPOILER.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.SendSpoiler(message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.SPOILER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.SHRUG.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendWithPrefix(MessagePrefix.Shrug, message)
|
||||||
|
}
|
||||||
|
Command.LENNY.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendWithPrefix(MessagePrefix.Lenny, message)
|
||||||
|
}
|
||||||
|
Command.TABLE_FLIP.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, message)
|
||||||
|
}
|
||||||
|
Command.UNFLIP.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendWithPrefix(MessagePrefix.Unflip, message)
|
||||||
|
}
|
||||||
|
Command.DISCARD_SESSION.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 1) {
|
||||||
|
SlashCommand.DiscardSession
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.DISCARD_SESSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.WHOIS.matches(slashCommand) -> {
|
||||||
|
parseUserId(messageParts)
|
||||||
|
?.let { userId ->
|
||||||
|
SlashCommand.ShowUser(
|
||||||
|
userId = userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: syntaxError(Command.WHOIS)
|
||||||
|
}
|
||||||
|
Command.CONFETTI.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, message)
|
||||||
|
}
|
||||||
|
Command.SNOWFALL.matches(slashCommand) -> {
|
||||||
|
SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, message)
|
||||||
|
}
|
||||||
|
Command.LEAVE_ROOM.matches(slashCommand) -> {
|
||||||
|
if (messageParts.size == 1) {
|
||||||
|
SlashCommand.LeaveRoom
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.LEAVE_ROOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.UPGRADE_ROOM.matches(slashCommand) -> {
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
SlashCommand.UpgradeRoom(newVersion = message)
|
||||||
|
} else {
|
||||||
|
syntaxError(Command.UPGRADE_ROOM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command.CRASH_APP.matches(slashCommand) && appPreferencesStore.isDeveloperModeEnabledFlow().first() -> {
|
||||||
|
error("Application crashed from user demand")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Unknown command
|
||||||
|
SlashCommand.ErrorUnknownSlashCommand(
|
||||||
|
stringProvider.getString(R.string.slash_command_unrecognized, slashCommand)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseUserId(messageParts: List<String>): UserId? {
|
||||||
|
val str = messageParts.getOrNull(1) ?: return null
|
||||||
|
return when {
|
||||||
|
MatrixPatterns.isUserId(str) -> str
|
||||||
|
str == "<a" -> {
|
||||||
|
// Rich text editor mode
|
||||||
|
messageParts.getOrNull(2)?.let { html ->
|
||||||
|
// html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org</a>"
|
||||||
|
val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)</a>".toRegex()
|
||||||
|
val matchResult = regex.find(html)
|
||||||
|
val userId = matchResult?.groupValues?.getOrNull(1)
|
||||||
|
userId?.takeIf {
|
||||||
|
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Can be markdown format like "[@user:domain.org](https://matrix.to/#/@user:domain.org)"
|
||||||
|
val regex = "\\[([^\\]]+)]\\(https://matrix.to/#/([^\\]]+)\\)".toRegex()
|
||||||
|
val matchResult = regex.find(str)
|
||||||
|
val userId = matchResult?.groupValues?.getOrNull(1)
|
||||||
|
userId?.takeIf {
|
||||||
|
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?.let(::UserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syntaxError(command: Command) = SlashCommand.ErrorSyntax(
|
||||||
|
stringProvider.getString(
|
||||||
|
R.string.slash_command_parameters_error,
|
||||||
|
command.command,
|
||||||
|
buildString {
|
||||||
|
append(command.command)
|
||||||
|
if (command.parameters != null) {
|
||||||
|
append(" ${command.parameters}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun extractMessage(message: String): Pair<List<String>, String>? {
|
||||||
|
val messageParts = try {
|
||||||
|
message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "## parseSlashCommand() : split failed")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// test if the string cut fails
|
||||||
|
if (messageParts.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val slashCommand = messageParts.first()
|
||||||
|
val trimmedMessage = message.substring(slashCommand.length).trim()
|
||||||
|
|
||||||
|
return messageParts to trimmedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notSupportedThreadsCommands: List<Command> by lazy {
|
||||||
|
Command.entries.filter {
|
||||||
|
!it.isAllowedInThread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the current command is not supported by threads.
|
||||||
|
* @param isInThreadTimeline if its true we are in a thread timeline
|
||||||
|
* @param slashCommand the slash command that will be checked
|
||||||
|
* @return The command that is not supported
|
||||||
|
*/
|
||||||
|
private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? {
|
||||||
|
return if (isInThreadTimeline) {
|
||||||
|
notSupportedThreadsCommands.firstOrNull {
|
||||||
|
it.command == slashCommand
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimParts(message: CharSequence, messageParts: List<String>): String? {
|
||||||
|
val partsSize = messageParts.sumOf { it.length }
|
||||||
|
val gapsNumber = messageParts.size - 1
|
||||||
|
return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultSlashCommandService(
|
||||||
|
private val commandParser: CommandParser,
|
||||||
|
private val commandExecutor: CommandExecutor,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
) : SlashCommandService {
|
||||||
|
override suspend fun getSuggestions(
|
||||||
|
text: String,
|
||||||
|
isInThread: Boolean,
|
||||||
|
): List<SlashCommandSuggestion> {
|
||||||
|
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
|
||||||
|
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
|
||||||
|
return Command.entries.filter {
|
||||||
|
it.startsWith(text)
|
||||||
|
}.filter {
|
||||||
|
!isInThread || it.isAllowedInThread
|
||||||
|
}.filter {
|
||||||
|
!it.isDevCommand || isDeveloperModeEnabled
|
||||||
|
}.map {
|
||||||
|
SlashCommandSuggestion(
|
||||||
|
command = it.command,
|
||||||
|
parameters = it.parameters,
|
||||||
|
description = stringProvider.getString(it.description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun parse(
|
||||||
|
textMessage: CharSequence,
|
||||||
|
formattedMessage: String?,
|
||||||
|
isInThreadTimeline: Boolean,
|
||||||
|
): SlashCommand {
|
||||||
|
return commandParser.parseSlashCommand(
|
||||||
|
textMessage = textMessage,
|
||||||
|
formattedMessage = formattedMessage,
|
||||||
|
isInThreadTimeline = isInThreadTimeline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun proceedSendMessage(
|
||||||
|
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||||
|
timeline: Timeline,
|
||||||
|
): Result<Unit> {
|
||||||
|
return commandExecutor.proceedSendMessage(
|
||||||
|
slashCommand = slashCommand,
|
||||||
|
timeline = timeline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun proceedAdmin(
|
||||||
|
slashCommand: SlashCommand.SlashCommandAdmin,
|
||||||
|
): Result<Unit> {
|
||||||
|
return commandExecutor.proceedAdmin(
|
||||||
|
slashCommand = slashCommand,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl.rainbow
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspired from React-Sdk
|
||||||
|
* Ref: https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/utils/colour.js
|
||||||
|
*/
|
||||||
|
@Inject
|
||||||
|
class RainbowGenerator {
|
||||||
|
fun generate(text: String): String {
|
||||||
|
val split = text.splitEmoji()
|
||||||
|
val frequency = 2 * Math.PI / split.size
|
||||||
|
|
||||||
|
return split
|
||||||
|
.mapIndexed { idx, letter ->
|
||||||
|
// Do better than React-Sdk: Avoid adding font color for spaces
|
||||||
|
if (letter == " ") {
|
||||||
|
"$letter"
|
||||||
|
} else {
|
||||||
|
val (a, b) = generateAB(idx * frequency, 1f)
|
||||||
|
val dashColor = labToRGB(75, a, b).toDashColor()
|
||||||
|
"<font color=\"$dashColor\">$letter</font>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.joinToString(separator = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateAB(hue: Double, chroma: Float): Pair<Double, Double> {
|
||||||
|
val a = chroma * 127 * cos(hue)
|
||||||
|
val b = chroma * 127 * sin(hue)
|
||||||
|
|
||||||
|
return Pair(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun labToRGB(l: Int, a: Double, b: Double): RgbColor {
|
||||||
|
// Convert CIELAB to CIEXYZ (D65)
|
||||||
|
var y = (l + 16) / 116.0
|
||||||
|
val x = adjustXYZ(y + a / 500) * 0.9505
|
||||||
|
val z = adjustXYZ(y - b / 200) * 1.0890
|
||||||
|
|
||||||
|
y = adjustXYZ(y)
|
||||||
|
|
||||||
|
// Linear transformation from CIEXYZ to RGB
|
||||||
|
val red = 3.24096994 * x - 1.53738318 * y - 0.49861076 * z
|
||||||
|
val green = -0.96924364 * x + 1.8759675 * y + 0.04155506 * z
|
||||||
|
val blue = 0.05563008 * x - 0.20397696 * y + 1.05697151 * z
|
||||||
|
|
||||||
|
return RgbColor(adjustRGB(red), adjustRGB(green), adjustRGB(blue))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustXYZ(value: Double): Double {
|
||||||
|
if (value > 0.2069) {
|
||||||
|
return value.pow(3)
|
||||||
|
}
|
||||||
|
return 0.1284 * value - 0.01771
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gammaCorrection(value: Double): Double {
|
||||||
|
// Non-linear transformation to sRGB
|
||||||
|
if (value <= 0.0031308) {
|
||||||
|
return 12.92 * value
|
||||||
|
}
|
||||||
|
return 1.055 * value.pow(1 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustRGB(value: Double): Int {
|
||||||
|
return (gammaCorrection(value)
|
||||||
|
.coerceIn(0.0, 1.0) * 255)
|
||||||
|
.roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as split, but considering emojis.
|
||||||
|
*/
|
||||||
|
private fun CharSequence.splitEmoji(): List<CharSequence> {
|
||||||
|
val result = mutableListOf<CharSequence>()
|
||||||
|
var index = 0
|
||||||
|
while (index < length) {
|
||||||
|
val firstChar = get(index)
|
||||||
|
if (firstChar.code == 0x200e) {
|
||||||
|
// Left to right mark. What should I do with it?
|
||||||
|
} else if (firstChar.code in 0xD800..0xDBFF && index + 1 < length) {
|
||||||
|
// We have the start of a surrogate pair
|
||||||
|
val secondChar = get(index + 1)
|
||||||
|
if (secondChar.code in 0xDC00..0xDFFF) {
|
||||||
|
// We have an emoji
|
||||||
|
result.add("$firstChar$secondChar")
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
// Not sure what we have here...
|
||||||
|
result.add("$firstChar")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular char
|
||||||
|
result.add("$firstChar")
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl.rainbow
|
||||||
|
|
||||||
|
data class RgbColor(
|
||||||
|
val r: Int,
|
||||||
|
val g: Int,
|
||||||
|
val b: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun RgbColor.toDashColor(): String {
|
||||||
|
return listOf(r, g, b)
|
||||||
|
.joinToString(separator = "", prefix = "#") {
|
||||||
|
it.toString(16).padStart(2, '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ 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.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="slash_command_error">Command error</string>
|
||||||
|
<string name="slash_command_unrecognized">Unrecognized command: %1$s</string>
|
||||||
|
<string name="slash_command_parameters_error">The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s</string>
|
||||||
|
<string name="slash_command_not_supported_in_threads">The command \"%1$s\" is recognized but not supported in threads.</string>
|
||||||
|
<string name="slash_command_description_emote">Displays action</string>
|
||||||
|
<string name="slash_command_description_crash_application">Crash the application.</string>
|
||||||
|
<string name="slash_command_description_ban_user">Bans user with given id</string>
|
||||||
|
<string name="slash_command_description_unban_user">Unbans user with given id</string>
|
||||||
|
<string name="slash_command_description_ignore_user">Ignores a user, hiding their messages from you</string>
|
||||||
|
<string name="slash_command_description_unignore_user">Stops ignoring a user, showing their messages going forward</string>
|
||||||
|
<string name="slash_command_description_op_user">Define the power level of a user</string>
|
||||||
|
<string name="slash_command_description_deop_user">Deops user with given id</string>
|
||||||
|
<string name="slash_command_description_room_name">Sets the room name</string>
|
||||||
|
<string name="slash_command_description_rainbow">Sends the given message colored as a rainbow</string>
|
||||||
|
<string name="slash_command_description_rainbow_emote">Sends the given emote colored as a rainbow</string>
|
||||||
|
<string name="slash_command_description_invite_user">Invites user with given id to current room</string>
|
||||||
|
<string name="slash_command_description_join_room">Joins room with given address</string>
|
||||||
|
<string name="slash_command_description_spoiler">Sends the given message as a spoiler</string>
|
||||||
|
<string name="slash_command_description_topic">Set the room topic</string>
|
||||||
|
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
|
||||||
|
<string name="slash_command_description_nick">Changes your display nickname</string>
|
||||||
|
<string name="slash_command_confetti">Sends the given message with confetti</string>
|
||||||
|
<string name="slash_command_snow">Sends the given message with snowfall</string>
|
||||||
|
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>
|
||||||
|
<string name="slash_command_description_nick_for_room">Changes your display nickname in the current room only</string>
|
||||||
|
<string name="slash_command_description_room_avatar">Changes the avatar of the current room</string>
|
||||||
|
<string name="slash_command_description_avatar_for_room">Changes your avatar in this current room only</string>
|
||||||
|
<string name="slash_command_description_devtools">Open the developer tools screen</string>
|
||||||
|
<string name="slash_command_description_whois">Displays information about a user</string>
|
||||||
|
<string name="slash_command_description_shrug">Prepends ¯\\_(ツ)_/¯ to a plain-text message</string>
|
||||||
|
<string name="slash_command_description_lenny">Prepends ( ͡° ͜ʖ ͡°) to a plain-text message</string>
|
||||||
|
<string name="slash_command_description_table_flip">Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message</string>
|
||||||
|
<string name="slash_command_description_unflip">Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message</string>
|
||||||
|
<string name="slash_command_description_discard_session">Forces the current outbound group session in an encrypted room to be discarded</string>
|
||||||
|
<string name="slash_command_description_discard_session_not_handled">Only supported in encrypted rooms</string>
|
||||||
|
<string name="slash_command_description_leave_room">Leave the current room</string>
|
||||||
|
<string name="slash_command_description_upgrade_room">Upgrades a room to a new version</string>
|
||||||
|
|
||||||
|
<string name="common_spoiler">Spoiler</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
|
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||||
|
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CommandExecutorTest {
|
||||||
|
@Test
|
||||||
|
fun `send plain text delegates to timeline with plain flag`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
var capturedHtml: String? = "initial"
|
||||||
|
var capturedAsPlainText = false
|
||||||
|
timeline.sendMessageLambda = { body, htmlBody, _, _, asPlainText ->
|
||||||
|
capturedBody = body
|
||||||
|
capturedHtml = htmlBody
|
||||||
|
capturedAsPlainText = asPlainText
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendPlainText("hello"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("hello")
|
||||||
|
assertThat(capturedHtml).isNull()
|
||||||
|
assertThat(capturedAsPlainText).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send emote delegates to timeline as emote`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var msgType: MsgType? = null
|
||||||
|
timeline.sendMessageLambda = { _, _, _, type, _ ->
|
||||||
|
msgType = type
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendEmote("yay"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(msgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send lenny prefixes message`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("( ͡° ͜ʖ ͡°) fun")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send table flip prefixes message`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("(╯°□°)╯︵ ┻━┻ wow")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send unflip prefixes message`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "keep cool"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("┬──┬ ノ( ゜-゜ノ) keep cool")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send shrug prefixes message`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
timeline.sendMessageLambda = { body, _, _, _, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "wow"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("¯\\\\_(ツ)\\_/¯ wow")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send rainbow provides html body`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedHtml: String? = null
|
||||||
|
var capturedBody: String? = null
|
||||||
|
var capturedMsgType: MsgType? = null
|
||||||
|
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
capturedHtml = htmlBody
|
||||||
|
capturedMsgType = msgType
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendRainbow("a nice rainbow"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("a nice rainbow")
|
||||||
|
assertThat(capturedHtml).isNotNull()
|
||||||
|
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
|
||||||
|
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_TEXT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send rainbow emote provides html body`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedHtml: String? = null
|
||||||
|
var capturedBody: String? = null
|
||||||
|
var capturedMsgType: MsgType? = null
|
||||||
|
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
capturedHtml = htmlBody
|
||||||
|
capturedMsgType = msgType
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendRainbowEmote("a nice rainbow"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("a nice rainbow")
|
||||||
|
assertThat(capturedHtml).isNotNull()
|
||||||
|
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
|
||||||
|
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `change display name invokes the method of the matrix client`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient()
|
||||||
|
val sut = createCommandExecutor(matrixClient = matrixClient)
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayName("new name"))
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(matrixClient.setDisplayNameCalled).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `change room avatar is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.ChangeRoomAvatar(AN_AVATAR_URL))
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `change avatar for room is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.ChangeAvatarForRoom(AN_AVATAR_URL))
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `change display name for room is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayNameForRoom(A_USER_NAME))
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upgrade room is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.UpgradeRoom("1"))
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `set user power level is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `discard session is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedAdmin(SlashCommand.DiscardSession)
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send spoiler sets formatted and body includes spoiler label`() = runTest {
|
||||||
|
val timeline = FakeTimeline()
|
||||||
|
var capturedBody: String? = null
|
||||||
|
var capturedHtml: String? = null
|
||||||
|
timeline.sendMessageLambda = { body, htmlBody, _, _, _ ->
|
||||||
|
capturedBody = body
|
||||||
|
capturedHtml = htmlBody
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val stringProvider = FakeStringProvider(defaultResult = "SPOILER")
|
||||||
|
val sut = createCommandExecutor(
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
)
|
||||||
|
val res = sut.proceedSendMessage(SlashCommand.SendSpoiler("secret"), timeline)
|
||||||
|
assertThat(res.isSuccess).isTrue()
|
||||||
|
assertThat(capturedBody).isEqualTo("[SPOILER](secret)")
|
||||||
|
assertThat(capturedHtml).isEqualTo("<span data-mx-spoiler>secret</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `send chat effect is not supported`() = runTest {
|
||||||
|
val sut = createCommandExecutor()
|
||||||
|
val res = sut.proceedSendMessage(
|
||||||
|
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, A_MESSAGE),
|
||||||
|
FakeTimeline()
|
||||||
|
)
|
||||||
|
assertThat(res.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin commands call underlying client and room APIs`() = runTest {
|
||||||
|
var kicked = false
|
||||||
|
var banned = false
|
||||||
|
var unbanned = false
|
||||||
|
var invited = false
|
||||||
|
var ignored = false
|
||||||
|
var unignored = false
|
||||||
|
var left = false
|
||||||
|
var topicSet = false
|
||||||
|
var nameSet = false
|
||||||
|
var joined = false
|
||||||
|
|
||||||
|
val joinedRoom = FakeJoinedRoom(
|
||||||
|
kickUserResult = { _, _ ->
|
||||||
|
kicked = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
banUserResult = { _, _ ->
|
||||||
|
banned = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
unBanUserResult = { _, _ ->
|
||||||
|
unbanned = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
inviteUserResult = { _ ->
|
||||||
|
invited = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
setTopicResult = { _ ->
|
||||||
|
topicSet = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
setNameResult = { _ ->
|
||||||
|
nameSet = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
leaveRoomLambda = {
|
||||||
|
left = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val matrixClient = FakeMatrixClient(
|
||||||
|
ignoreUserResult = { _ ->
|
||||||
|
ignored = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
unIgnoreUserResult = { _ ->
|
||||||
|
unignored = true
|
||||||
|
Result.success(Unit)
|
||||||
|
},
|
||||||
|
).apply {
|
||||||
|
joinRoomByIdOrAliasLambda = { _, _ ->
|
||||||
|
joined = true
|
||||||
|
Result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sut = createCommandExecutor(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
joinedRoom = joinedRoom,
|
||||||
|
)
|
||||||
|
val kickRes = sut.proceedAdmin(SlashCommand.RemoveUser(A_USER_ID, null))
|
||||||
|
assertThat(kicked).isTrue()
|
||||||
|
assertThat(kickRes.isSuccess).isTrue()
|
||||||
|
val banRes = sut.proceedAdmin(SlashCommand.BanUser(A_USER_ID, "reason"))
|
||||||
|
assertThat(banned).isTrue()
|
||||||
|
assertThat(banRes.isSuccess).isTrue()
|
||||||
|
val unbanRes = sut.proceedAdmin(SlashCommand.UnbanUser(A_USER_ID, null))
|
||||||
|
assertThat(unbanned).isTrue()
|
||||||
|
assertThat(unbanRes.isSuccess).isTrue()
|
||||||
|
val inviteRes = sut.proceedAdmin(SlashCommand.Invite(A_USER_ID, null))
|
||||||
|
assertThat(invited).isTrue()
|
||||||
|
assertThat(inviteRes.isSuccess).isTrue()
|
||||||
|
val ignoreRes = sut.proceedAdmin(SlashCommand.IgnoreUser(A_USER_ID))
|
||||||
|
assertThat(ignoreRes.isSuccess).isTrue()
|
||||||
|
assertThat(ignored).isTrue()
|
||||||
|
val unignoreRes = sut.proceedAdmin(SlashCommand.UnignoreUser(A_USER_ID))
|
||||||
|
assertThat(unignoreRes.isSuccess).isTrue()
|
||||||
|
assertThat(unignored).isTrue()
|
||||||
|
val leaveRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
|
||||||
|
assertThat(leaveRes.isSuccess).isTrue()
|
||||||
|
assertThat(left).isTrue()
|
||||||
|
val topicRes = sut.proceedAdmin(SlashCommand.ChangeTopic("t"))
|
||||||
|
assertThat(topicRes.isSuccess).isTrue()
|
||||||
|
assertThat(topicSet).isTrue()
|
||||||
|
val nameRes = sut.proceedAdmin(SlashCommand.ChangeRoomName("n"))
|
||||||
|
assertThat(nameRes.isSuccess).isTrue()
|
||||||
|
assertThat(nameSet).isTrue()
|
||||||
|
val joinRes = sut.proceedAdmin(
|
||||||
|
SlashCommand.JoinRoom(
|
||||||
|
roomIdOrAlias = RoomIdOrAlias.Id(
|
||||||
|
RoomId("!room:domain")
|
||||||
|
),
|
||||||
|
reason = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(joinRes.isSuccess).isTrue()
|
||||||
|
assertThat(joined).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCommandExecutor(
|
||||||
|
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||||
|
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(),
|
||||||
|
rainbowGenerator: RainbowGenerator = RainbowGenerator(),
|
||||||
|
stringProvider: StringProvider = FakeStringProvider(),
|
||||||
|
) = CommandExecutor(
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
joinedRoom = joinedRoom,
|
||||||
|
rainbowGenerator = rainbowGenerator,
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import io.element.android.libraries.slashcommands.api.ChatEffect
|
||||||
|
import io.element.android.libraries.slashcommands.api.MessagePrefix
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CommandParserTest {
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandEmpty() = runTest {
|
||||||
|
test("/", SlashCommand.ErrorEmptySlashCommand("A string/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandUnknown() = runTest {
|
||||||
|
test("/unknown", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
|
||||||
|
test("/unknown with param", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandNotACommand() = runTest {
|
||||||
|
test("", SlashCommand.NotACommand)
|
||||||
|
test("test", SlashCommand.NotACommand)
|
||||||
|
test("// test", SlashCommand.NotACommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandEmote() = runTest {
|
||||||
|
test("/me test", SlashCommand.SendEmote("test"))
|
||||||
|
test("/me", SlashCommand.ErrorSyntax("A string/me, /me <message>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandRemove() = runTest {
|
||||||
|
// Nominal
|
||||||
|
test("/remove $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
|
||||||
|
// With a reason
|
||||||
|
test("/remove $A_USER_ID a reason", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
|
||||||
|
// Trim the reason
|
||||||
|
test("/remove $A_USER_ID a reason ", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
|
||||||
|
// Alias
|
||||||
|
test("/kick $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
|
||||||
|
// Error
|
||||||
|
test("/remove", SlashCommand.ErrorSyntax("A string/remove, /remove <user-id> [reason]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandRemoveMarkdown() = runTest {
|
||||||
|
// Nominal
|
||||||
|
test(
|
||||||
|
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org)",
|
||||||
|
SlashCommand.RemoveUser(UserId("@user:domain.org"), null)
|
||||||
|
)
|
||||||
|
test(
|
||||||
|
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org) reason",
|
||||||
|
SlashCommand.RemoveUser(UserId("@user:domain.org"), "reason")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandPlainAndNick() = runTest {
|
||||||
|
test("/plain hello", SlashCommand.SendPlainText("hello"))
|
||||||
|
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
|
||||||
|
|
||||||
|
test("/nick John", SlashCommand.ChangeDisplayName("John"))
|
||||||
|
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandRoomNickAndAvatars() = runTest {
|
||||||
|
test("/myroomnick Roomy", SlashCommand.ChangeDisplayNameForRoom("Roomy"))
|
||||||
|
test("/roomavatar mxc://matrix.org/abc", SlashCommand.ChangeRoomAvatar("mxc://matrix.org/abc"))
|
||||||
|
test("/roomavatar http://notmxc", SlashCommand.ErrorSyntax("A string/roomavatar, /roomavatar <mxc_url>"))
|
||||||
|
test("/myroomavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatarForRoom("mxc://matrix.org/abc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandTopicAndRainbow() = runTest {
|
||||||
|
test("/topic New topic", SlashCommand.ChangeTopic("New topic"))
|
||||||
|
test("/topic", SlashCommand.ErrorSyntax("A string/topic, /topic <topic>"))
|
||||||
|
|
||||||
|
test("/rainbow yay", SlashCommand.SendRainbow("yay"))
|
||||||
|
test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow <message>"))
|
||||||
|
|
||||||
|
test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay"))
|
||||||
|
test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme <message>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandJoinAndRoomName() = runTest {
|
||||||
|
// valid join
|
||||||
|
test(
|
||||||
|
"/join !roomId:domain reason",
|
||||||
|
SlashCommand.JoinRoom(
|
||||||
|
RoomIdOrAlias.Id(RoomId("!roomId:domain")),
|
||||||
|
"reason"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// invalid join
|
||||||
|
test("/join notavalid", SlashCommand.ErrorSyntax("A string/join, /join <room-address> [reason]"))
|
||||||
|
|
||||||
|
test("/roomname My Room", SlashCommand.ChangeRoomName("My Room"))
|
||||||
|
test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname <name>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandInviteBanEtc() = runTest {
|
||||||
|
test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null))
|
||||||
|
test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite <user-id> [reason]"))
|
||||||
|
|
||||||
|
test("/ban $A_USER_ID bad", SlashCommand.BanUser(A_USER_ID, "bad"))
|
||||||
|
test("/unban $A_USER_ID", SlashCommand.UnbanUser(A_USER_ID, null))
|
||||||
|
|
||||||
|
test("/ignore $A_USER_ID", SlashCommand.IgnoreUser(A_USER_ID))
|
||||||
|
test("/unignore $A_USER_ID", SlashCommand.UnignoreUser(A_USER_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandPowerLevels() = runTest {
|
||||||
|
test("/op $A_USER_ID 50", SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
|
||||||
|
test("/op $A_USER_ID notnumber", SlashCommand.ErrorSyntax("A string/op, /op <user-id> [<power-level>]"))
|
||||||
|
test("/deop $A_USER_ID", SlashCommand.SetUserPowerLevel(A_USER_ID, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandDevtoolsAndSpoiler() = runTest {
|
||||||
|
test("/devtools", SlashCommand.DevTools)
|
||||||
|
test("/devtools extra", SlashCommand.ErrorSyntax("A string/devtools, /devtools"))
|
||||||
|
|
||||||
|
test("/spoiler secret", SlashCommand.SendSpoiler("secret"))
|
||||||
|
test("/spoiler", SlashCommand.ErrorSyntax("A string/spoiler, /spoiler <message>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandEmojisAndSession() = runTest {
|
||||||
|
test("/shrug hello", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "hello"))
|
||||||
|
test("/shrug", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, ""))
|
||||||
|
|
||||||
|
test("/lenny fun", SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"))
|
||||||
|
test("/tableflip wow", SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"))
|
||||||
|
test("/unflip be safe", SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "be safe"))
|
||||||
|
|
||||||
|
test("/discardsession", SlashCommand.DiscardSession)
|
||||||
|
test("/discardsession extra", SlashCommand.ErrorSyntax("A string/discardsession, /discardsession"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandWhoisAndEffectsAndLeave() = runTest {
|
||||||
|
test("/whois $A_USER_ID", SlashCommand.ShowUser(A_USER_ID))
|
||||||
|
|
||||||
|
test("/confetti party", SlashCommand.SendChatEffect(ChatEffect.CONFETTI, "party"))
|
||||||
|
test("/snowfall snow", SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, "snow"))
|
||||||
|
|
||||||
|
test("/leave", SlashCommand.LeaveRoom)
|
||||||
|
test("/leave now", SlashCommand.ErrorSyntax("A string/leave, /leave"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSlashCommandUpgradeAndCrashAndFeatureFlagAndThreads() = runTest {
|
||||||
|
test("/upgraderoom 9", SlashCommand.UpgradeRoom("9"))
|
||||||
|
test("/upgraderoom", SlashCommand.ErrorSyntax("A string/upgraderoom, /upgraderoom newVersion"))
|
||||||
|
|
||||||
|
// Crash only when developer mode enabled
|
||||||
|
val cpDev = createCommandParser(appPreferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true))
|
||||||
|
try {
|
||||||
|
cpDev.parseSlashCommand("/crash", null, false)
|
||||||
|
org.junit.Assert.fail("Expected crash to throw")
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature flag disabled
|
||||||
|
val cpFF = createCommandParser(featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SlashCommand.key to false)))
|
||||||
|
val res = cpFF.parseSlashCommand("/me test", null, false)
|
||||||
|
assertThat(res).isEqualTo(SlashCommand.NotACommand)
|
||||||
|
|
||||||
|
// Not supported in threads (e.g. /join)
|
||||||
|
val cpThread = createCommandParser()
|
||||||
|
val threadRes = cpThread.parseSlashCommand("/join !roomId:domain", null, true)
|
||||||
|
assertThat(threadRes).isInstanceOf(SlashCommand.ErrorCommandNotSupportedInThreads::class.java)
|
||||||
|
assertThat((threadRes as SlashCommand.ErrorCommandNotSupportedInThreads).message).isEqualTo("A string/join")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun test(message: String, expectedResult: SlashCommand) {
|
||||||
|
val commandParser = createCommandParser()
|
||||||
|
val result = commandParser.parseSlashCommand(message, null, false)
|
||||||
|
assertThat(result).isEqualTo(expectedResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createCommandParser(
|
||||||
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(
|
||||||
|
FeatureFlags.SlashCommand.key to true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
stringProvider: StringProvider = FakeStringProvider(),
|
||||||
|
) = CommandParser(
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.impl
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
|
||||||
|
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||||
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultSlashCommandServiceTest {
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions filters by text and maps to suggestions`() = runTest {
|
||||||
|
val stringProvider = FakeStringProvider(defaultResult = "desc")
|
||||||
|
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||||
|
val sut = createDefaultSlashCommandService(
|
||||||
|
commandParser = CommandParser(
|
||||||
|
appPreferencesStore = prefs,
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(
|
||||||
|
FeatureFlags.SlashCommand.key to true,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
),
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
appPreferencesStore = prefs,
|
||||||
|
)
|
||||||
|
val res = sut.getSuggestions("ra", isInThread = true)
|
||||||
|
// Expect commands starting with "/ra" (case-insensitive) and that are allowed in threads
|
||||||
|
assertThat(res).isNotEmpty()
|
||||||
|
assertThat(res.first().description).isEqualTo("desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions hides dev commands when developer mode disabled`() = runTest {
|
||||||
|
val stringProvider = FakeStringProvider()
|
||||||
|
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||||
|
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||||
|
val all = sut.getSuggestions("crash", isInThread = true)
|
||||||
|
assertThat(all).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions returns empty list when the feature is enabled`() = runTest {
|
||||||
|
val sut = createDefaultSlashCommandService(isFeatureEnabled = true)
|
||||||
|
val all = sut.getSuggestions("me", isInThread = false)
|
||||||
|
assertThat(all).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions returns empty list when the feature is disabled`() = runTest {
|
||||||
|
val sut = createDefaultSlashCommandService(isFeatureEnabled = false)
|
||||||
|
val all = sut.getSuggestions("me", isInThread = false)
|
||||||
|
assertThat(all).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions for aliases`() = runTest {
|
||||||
|
val stringProvider = FakeStringProvider()
|
||||||
|
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
|
||||||
|
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||||
|
val all = sut.getSuggestions("part", isInThread = true)
|
||||||
|
assertThat(all).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSuggestions shows dev commands when developer mode enabled`() = runTest {
|
||||||
|
val stringProvider = FakeStringProvider()
|
||||||
|
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true)
|
||||||
|
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
|
||||||
|
val all = sut.getSuggestions("crash", isInThread = true)
|
||||||
|
assertThat(all).isNotEmpty()
|
||||||
|
assertThat(all.first().command).isEqualTo("/crash")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse delegates to commandParser`() = runTest {
|
||||||
|
val sut = createDefaultSlashCommandService()
|
||||||
|
val res = sut.parse("test", null, false)
|
||||||
|
assertThat(res).isEqualTo(SlashCommand.NotACommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `proceedSendMessage delegate to commandExecutor`() = runTest {
|
||||||
|
val sendMessage = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createDefaultSlashCommandService()
|
||||||
|
val sendRes = sut.proceedSendMessage(
|
||||||
|
slashCommand = SlashCommand.SendPlainText("hi"),
|
||||||
|
timeline = FakeTimeline().apply {
|
||||||
|
sendMessageLambda = sendMessage
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertThat(sendRes.isSuccess).isTrue()
|
||||||
|
sendMessage.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `proceedAdmin delegates to commandExecutor`() = runTest {
|
||||||
|
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val sut = createDefaultSlashCommandService(
|
||||||
|
commandExecutor = CommandExecutor(
|
||||||
|
matrixClient = FakeMatrixClient(),
|
||||||
|
joinedRoom = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
leaveRoomLambda = leaveRoomLambda
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rainbowGenerator = RainbowGenerator(),
|
||||||
|
stringProvider = FakeStringProvider(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val adminRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
|
||||||
|
assertThat(adminRes.isSuccess).isTrue()
|
||||||
|
leaveRoomLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDefaultSlashCommandService(
|
||||||
|
isFeatureEnabled: Boolean = true,
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(
|
||||||
|
FeatureFlags.SlashCommand.key to isFeatureEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||||
|
stringProvider: StringProvider = FakeStringProvider(),
|
||||||
|
commandParser: CommandParser = createCommandParser(
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
),
|
||||||
|
commandExecutor: CommandExecutor = createCommandExecutor(
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
),
|
||||||
|
) = DefaultSlashCommandService(
|
||||||
|
commandParser = commandParser,
|
||||||
|
commandExecutor = commandExecutor,
|
||||||
|
stringProvider = stringProvider,
|
||||||
|
appPreferencesStore = appPreferencesStore,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
)
|
||||||
|
}
|
||||||
19
libraries/slashcommands/test/build.gradle.kts
Normal file
19
libraries/slashcommands/test/build.gradle.kts
Normal 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.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.slashcommands.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.slashcommands.api)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.tests.testutils)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* 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.slashcommands.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommand
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
|
||||||
|
class FakeSlashCommandService(
|
||||||
|
private val getSuggestionsResult: (String, Boolean) -> List<SlashCommandSuggestion> = { _, _ -> lambdaError() },
|
||||||
|
private val parseResult: (CharSequence, String?, Boolean) -> SlashCommand = { _, _, _ -> lambdaError() },
|
||||||
|
private val proceedSendMessageResult: (SlashCommand.SlashCommandSendMessage, Timeline) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
|
private val proceedAdminResult: (SlashCommand.SlashCommandAdmin) -> Result<Unit> = { lambdaError() },
|
||||||
|
) : SlashCommandService {
|
||||||
|
override suspend fun getSuggestions(text: String, isInThread: Boolean): List<SlashCommandSuggestion> = simulateLongTask {
|
||||||
|
getSuggestionsResult(text, isInThread)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun parse(
|
||||||
|
textMessage: CharSequence,
|
||||||
|
formattedMessage: String?,
|
||||||
|
isInThreadTimeline: Boolean,
|
||||||
|
): SlashCommand = simulateLongTask {
|
||||||
|
parseResult(textMessage, formattedMessage, isInThreadTimeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun proceedSendMessage(
|
||||||
|
slashCommand: SlashCommand.SlashCommandSendMessage,
|
||||||
|
timeline: Timeline,
|
||||||
|
): Result<Unit> = simulateLongTask {
|
||||||
|
proceedSendMessageResult(slashCommand, timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun proceedAdmin(slashCommand: SlashCommand.SlashCommandAdmin): Result<Unit> = simulateLongTask {
|
||||||
|
proceedAdminResult(slashCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiUtils)
|
implementation(projects.libraries.uiUtils)
|
||||||
|
implementation(projects.libraries.slashcommands.api)
|
||||||
|
|
||||||
releaseApi(libs.matrix.richtexteditor)
|
releaseApi(libs.matrix.richtexteditor)
|
||||||
releaseApi(libs.matrix.richtexteditor.compose)
|
releaseApi(libs.matrix.richtexteditor.compose)
|
||||||
|
|||||||
@@ -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.RoomAlias
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface ResolvedSuggestion {
|
sealed interface ResolvedSuggestion {
|
||||||
@@ -32,4 +33,8 @@ sealed interface ResolvedSuggestion {
|
|||||||
size = size,
|
size = size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Command(
|
||||||
|
val command: SlashCommandSuggestion,
|
||||||
|
) : ResolvedSuggestion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,21 +61,29 @@ class MarkdownTextEditorState(
|
|||||||
}
|
}
|
||||||
is ResolvedSuggestion.Member -> {
|
is ResolvedSuggestion.Member -> {
|
||||||
val currentText = SpannableStringBuilder(text.value())
|
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, "@ ")
|
currentText.replace(suggestion.start, suggestion.end, "@ ")
|
||||||
val end = suggestion.start + 1
|
val end = suggestion.start + 1
|
||||||
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
this.text.update(currentText, true)
|
text.update(currentText, true)
|
||||||
this.selection = IntRange(end + 1, end + 1)
|
selection = IntRange(end + 1, end + 1)
|
||||||
}
|
}
|
||||||
is ResolvedSuggestion.Alias -> {
|
is ResolvedSuggestion.Alias -> {
|
||||||
val currentText = SpannableStringBuilder(text.value())
|
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, "# ")
|
currentText.replace(suggestion.start, suggestion.end, "# ")
|
||||||
val end = suggestion.start + 1
|
val end = suggestion.start + 1
|
||||||
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
this.text.update(currentText, true)
|
text.update(currentText, true)
|
||||||
this.selection = IntRange(end + 1, end + 1)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.FakePermalinkBuilder
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
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.impl.mentions.aMentionSpanProvider
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
import io.element.android.libraries.textcomposer.mentions.MentionSpan
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionType
|
import io.element.android.libraries.textcomposer.mentions.MentionType
|
||||||
@@ -42,6 +43,7 @@ class MarkdownTextEditorStateTest {
|
|||||||
val mentionSpanProvider = aMentionSpanProvider()
|
val mentionSpanProvider = aMentionSpanProvider()
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider)
|
state.insertSuggestion(suggestion, mentionSpanProvider)
|
||||||
assertThat(state.getMentions()).isEmpty()
|
assertThat(state.getMentions()).isEmpty()
|
||||||
|
assertThat(state.text.value().toString()).isEqualTo("Hello @")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,6 +55,7 @@ class MarkdownTextEditorStateTest {
|
|||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider)
|
state.insertSuggestion(suggestion, mentionSpanProvider)
|
||||||
|
assertThat(state.text.value().toString()).isEqualTo("Hello # ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -64,6 +67,19 @@ class MarkdownTextEditorStateTest {
|
|||||||
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
|
||||||
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
|
||||||
state.insertSuggestion(suggestion, mentionSpanProvider)
|
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
|
@Test
|
||||||
@@ -74,6 +90,7 @@ class MarkdownTextEditorStateTest {
|
|||||||
val mentionSpanProvider = aMentionSpanProvider()
|
val mentionSpanProvider = aMentionSpanProvider()
|
||||||
state.insertSuggestion(mention, mentionSpanProvider)
|
state.insertSuggestion(mention, mentionSpanProvider)
|
||||||
assertThat(state.getMentions()).isEmpty()
|
assertThat(state.getMentions()).isEmpty()
|
||||||
|
assertThat(state.text.value().toString()).isEqualTo("Hello @")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -91,6 +108,7 @@ class MarkdownTextEditorStateTest {
|
|||||||
val mentions = state.getMentions()
|
val mentions = state.getMentions()
|
||||||
assertThat(mentions).isNotEmpty()
|
assertThat(mentions).isNotEmpty()
|
||||||
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
|
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
|
||||||
|
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -107,15 +125,14 @@ class MarkdownTextEditorStateTest {
|
|||||||
val mentions = state.getMentions()
|
val mentions = state.getMentions()
|
||||||
assertThat(mentions).isNotEmpty()
|
assertThat(mentions).isNotEmpty()
|
||||||
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
||||||
|
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
|
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
|
||||||
val text = "No mentions here"
|
val text = "No mentions here"
|
||||||
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
|
||||||
|
|
||||||
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
|
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
|
||||||
|
|
||||||
assertThat(markdown).isEqualTo(text)
|
assertThat(markdown).isEqualTo(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,19 +145,17 @@ class MarkdownTextEditorStateTest {
|
|||||||
)
|
)
|
||||||
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
|
||||||
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
||||||
|
|
||||||
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
|
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
|
||||||
|
|
||||||
assertThat(markdown).isEqualTo(
|
assertThat(markdown).isEqualTo(
|
||||||
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" +
|
"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)"
|
" 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
|
@Test
|
||||||
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
|
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||||
|
|
||||||
assertThat(state.getMentions()).isEmpty()
|
assertThat(state.getMentions()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +163,7 @@ class MarkdownTextEditorStateTest {
|
|||||||
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
|
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
|
||||||
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
|
||||||
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
|
||||||
|
|
||||||
val mentions = state.getMentions()
|
val mentions = state.getMentions()
|
||||||
|
|
||||||
assertThat(mentions).isNotEmpty()
|
assertThat(mentions).isNotEmpty()
|
||||||
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
|
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
|
||||||
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
|
||||||
@@ -184,4 +197,14 @@ class MarkdownTextEditorStateTest {
|
|||||||
roomAvatarUrl = null
|
roomAvatarUrl = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aSlashCommandSuggestion(): ResolvedSuggestion.Command {
|
||||||
|
return ResolvedSuggestion.Command(
|
||||||
|
command = SlashCommandSuggestion(
|
||||||
|
command = "/rainbow",
|
||||||
|
parameters = "param",
|
||||||
|
description = "Make the text colorful 🌈",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
|||||||
implementation(project(":libraries:session-storage:impl"))
|
implementation(project(":libraries:session-storage:impl"))
|
||||||
implementation(project(":libraries:mediapickers:impl"))
|
implementation(project(":libraries:mediapickers:impl"))
|
||||||
implementation(project(":libraries:mediaupload:impl"))
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
|
implementation(project(":libraries:slashcommands:impl"))
|
||||||
implementation(project(":libraries:usersearch:impl"))
|
implementation(project(":libraries:usersearch:impl"))
|
||||||
implementation(project(":libraries:textcomposer:impl"))
|
implementation(project(":libraries:textcomposer:impl"))
|
||||||
implementation(project(":libraries:accountselect:impl"))
|
implementation(project(":libraries:accountselect:impl"))
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user