diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a676df1d32..44c1060e10 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -422,6 +422,10 @@ class LoggedInFlowNode( override fun navigateToGlobalNotificationSettings() { backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) } + + override fun navigateToDeveloperSettings() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.DeveloperSettings)) + } } val inputs = RoomFlowNode.Inputs( roomIdOrAlias = navTarget.roomIdOrAlias, @@ -744,11 +748,11 @@ private class AttachRoomOperation( } } + // Always create a new element, otherwise we wouldn't be navigating to the target event id or child node BackStackElement( - key = NavKey(roomTarget), - fromState = CREATED, - targetState = ACTIVE, - operation = this - ) + key = NavKey(roomTarget), + fromState = CREATED, + targetState = ACTIVE, + operation = this + ) } else { // Otherwise, just push the new node to the end of the backstack Push(roomTarget).invoke(currentElements) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index d0d3df590d..febd15e9c2 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -85,6 +85,7 @@ class JoinedRoomLoadedFlowNode( fun navigateToRoom(roomId: RoomId, serverNames: List) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun navigateToGlobalNotificationSettings() + fun navigateToDeveloperSettings() } data class Inputs( @@ -145,6 +146,10 @@ class JoinedRoomLoadedFlowNode( callback.navigateToGlobalNotificationSettings() } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + override fun navigateToRoom(roomId: RoomId, serverNames: List) { callback.navigateToRoom(roomId, serverNames) } @@ -252,6 +257,10 @@ class JoinedRoomLoadedFlowNode( override fun navigateToRoom(roomId: RoomId) { callback.navigateToRoom(roomId, emptyList()) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } val params = MessagesEntryPoint.Params( MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt index 40778ae353..2f17071870 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt @@ -16,4 +16,5 @@ class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback { override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun navigateToGlobalNotificationSettings() = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 92c27d9f21..46bc4a55df 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.location.impl.share 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.value import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index a23e337d2a..3eecd54f3e 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -38,6 +38,7 @@ interface MessagesEntryPoint : FeatureEntryPoint { fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) fun navigateToRoom(roomId: RoomId) + fun navigateToDeveloperSettings() } data class Params(val initialTarget: InitialTarget) : NodeInputs diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 01482d0df5..6ff7f7e322 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voicerecorder.api) @@ -104,4 +105,5 @@ dependencies { testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.recentemojis.test) + testImplementation(projects.libraries.slashcommands.test) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 38d0504258..d3dd21de67 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -293,6 +293,10 @@ class MessagesFlowNode( override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) createNode(buildContext, listOf(callback, inputs)) @@ -502,6 +506,10 @@ class MessagesFlowNode( override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } createNode(buildContext, listOf(inputs, callback)) } @@ -567,7 +575,7 @@ class MessagesFlowNode( assetType = event.content.assetType, ) NavTarget.LocationViewer( - mode = mode + mode = mode ).takeIf { locationService.isServiceAvailable() } } else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 2ec5c0bcbf..e475f579c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -23,6 +23,8 @@ interface MessagesNavigator { fun navigateToEditPoll(eventId: EventId) fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) + fun navigateToMember(userId: UserId) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun navigateToDeveloperSettings() fun close() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 0c0b3e5448..20cdc51035 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -105,7 +105,7 @@ class MessagesNode( private val timelineController = TimelineController(room, room.liveTimeline) private val presenter = presenterFactory.create( navigator = this, - composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = false), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), actionListPresenter = actionListPresenterFactory.create( postProcessor = TimelineItemActionPostProcessor.Default, @@ -130,6 +130,7 @@ class MessagesNode( fun navigateToRoomDetails() fun navigateToPinnedMessagesList() fun navigateToKnockRequestsList() + fun navigateToDeveloperSettings() } override fun onBuilt() { @@ -222,10 +223,18 @@ class MessagesNode( } } + override fun navigateToMember(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { callback.navigateToThread(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + private fun displaySameRoomToast() { context.toast(CommonStrings.screen_room_permalink_same_room_android) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt index ae82c60f2a..982ca7dfd7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt @@ -36,4 +36,5 @@ sealed interface MessageComposerEvent { data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent data object SaveDraft : MessageComposerEvent + data object ClearSlashError : MessageComposerEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ed22a5e2ee..90b91691a9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -15,6 +15,7 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.messages.impl.MessagesNavigator 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.draft.ComposerDraftService 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.timeline.TimelineController 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.core.extensions.runCatchingExceptions 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.preferences.api.store.SessionPreferencesStore 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.ResolvedSuggestion 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( @Assisted private val navigator: MessagesNavigator, @Assisted private val timelineController: TimelineController, + @Assisted private val isInThread: Boolean, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val room: JoinedRoom, private val mediaPickerProvider: PickerProvider, @@ -125,10 +132,15 @@ class MessageComposerPresenter( private val suggestionsProcessor: SuggestionsProcessor, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val notificationConversationService: NotificationConversationService, + private val slashCommandService: SlashCommandService, ) : Presenter { @AssistedFactory 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()) @@ -218,6 +230,8 @@ class MessageComposerPresenter( } ) + val slashCommandAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + LaunchedEffect(Unit) { val draft = draftService.loadDraft( roomId = room.roomId, @@ -246,12 +260,13 @@ class MessageComposerPresenter( sessionCoroutineScope.sendMessage( markdownTextEditorState = markdownTextEditorState, richTextEditorState = richTextEditorState, + slashCommandAction = slashCommandAction, ) } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( + attachment = Media( localMedia = localMediaFactory.createFromUri( uri = event.uri, mimeType = null, @@ -340,6 +355,9 @@ class MessageComposerPresenter( val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch richTextEditorState.insertMentionAtSuggestion(text = text, link = link) } + is ResolvedSuggestion.Command -> { + richTextEditorState.replaceSuggestion(suggestion.command.command) + } } } else if (markdownTextEditorState.currentSuggestion != null) { markdownTextEditorState.insertSuggestion( @@ -354,6 +372,9 @@ class MessageComposerPresenter( val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) sessionCoroutineScope.updateDraft(draft, isVolatile = false) } + MessageComposerEvent.ClearSlashError -> { + slashCommandAction.value = AsyncAction.Uninitialized + } } } @@ -385,6 +406,7 @@ class MessageComposerPresenter( suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, + slashCommandAction = slashCommandAction.value, eventSink = ::handleEvent, ) } @@ -422,6 +444,7 @@ class MessageComposerPresenter( roomAliasSuggestions = roomAliasSuggestions, currentUserId = currentUserId, canSendRoomMention = ::canSendRoomMention, + isInThread = isInThread, ) suggestions.clear() suggestions.addAll(result) @@ -433,9 +456,69 @@ class MessageComposerPresenter( private fun CoroutineScope.sendMessage( markdownTextEditorState: MarkdownTextEditorState, richTextEditorState: RichTextEditorState, + slashCommandAction: MutableState>, ) = launch { val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true) 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 resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 424e8c07b9..f3fdb3d59a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.messagecomposer 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.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState @@ -26,5 +27,6 @@ data class MessageComposerState( val suggestions: ImmutableList, val resolveMentionDisplay: (String, String) -> TextDisplay, val resolveAtRoomMentionDisplay: () -> TextDisplay, + val slashCommandAction: AsyncAction, val eventSink: (MessageComposerEvent) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index a06bf30dad..ef9cd7933b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.messagecomposer 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.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.TextEditorState @@ -32,6 +33,7 @@ fun aMessageComposerState( showAttachmentSourcePicker: Boolean = false, canShareLocation: Boolean = true, suggestions: ImmutableList = persistentListOf(), + slashCommandAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (MessageComposerEvent) -> Unit = {}, ) = MessageComposerState( textEditorState = textEditorState, @@ -43,5 +45,6 @@ fun aMessageComposerState( suggestions = suggestions, resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + slashCommandAction = slashCommandAction, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 4b346e0c15..d387bc8765 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -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.VoiceMessageComposerStateProvider 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.PreviewsDayNight import io.element.android.libraries.textcomposer.TextComposer @@ -115,6 +116,12 @@ internal fun MessageComposerView( onTyping = ::onTyping, onSelectRichContent = ::sendUri, ) + + AsyncActionView( + async = state.slashCommandAction, + onSuccess = {}, + onErrorDismiss = { state.eventSink(MessageComposerEvent.ClearSlashError) }, + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt index e9e38e1730..678ef2ba56 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt @@ -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.AvatarSize 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.preview.ElementPreview 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.RoomMembershipState 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 kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -63,6 +65,7 @@ fun SuggestionsPickerView( is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Alias -> suggestion.roomId.value + is ResolvedSuggestion.Command -> suggestion.command.command } } ) { @@ -91,54 +94,81 @@ private fun SuggestionItemView( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.clickable { onSelectSuggestion(suggestion) }, - horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clickable { onSelectSuggestion(suggestion) } + .padding(horizontal = 16.dp), ) { val avatarSize = AvatarSize.Suggestion val avatarData = when (suggestion) { is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) + is ResolvedSuggestion.Command -> null } val avatarType = when (suggestion) { - is ResolvedSuggestion.Alias -> AvatarType.Room() + is ResolvedSuggestion.Alias -> Room() ResolvedSuggestion.AtRoom, is ResolvedSuggestion.Member -> AvatarType.User + is ResolvedSuggestion.Command -> null } val title = when (suggestion) { is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedSuggestion.Member -> suggestion.roomMember.displayName 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) { is ResolvedSuggestion.AtRoom -> "@room" is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value is ResolvedSuggestion.Alias -> suggestion.roomAlias.value + is ResolvedSuggestion.Command -> suggestion.command.description + } + if (avatarData != null && avatarType != null) { + Avatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 16.dp), + ) } - Avatar( - avatarData = avatarData, - avatarType = avatarType, - modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp), - ) Column( modifier = Modifier .fillMaxWidth() - .padding(end = 16.dp, top = 8.dp, bottom = 8.dp) + .padding(top = 8.dp, bottom = 8.dp) .align(Alignment.CenterVertically), ) { - title?.let { - Text( - text = it, - style = ElementTheme.typography.fontBodyLgRegular, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + title?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + details?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + ) + } } Text( text = subtitle, style = ElementTheme.typography.fontBodySmRegular, color = ElementTheme.colors.textSecondary, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } @@ -174,7 +204,21 @@ internal fun SuggestionsPickerViewPreview() { roomId = RoomId("!room:matrix.org"), roomName = "My room", roomAvatarUrl = null, - ) + ), + ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/noparam", + parameters = null, + description = "A slash command without parameters", + ) + ), + ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/withparam", + parameters = " [reason]", + description = "A slash command with parameters", + ) + ), ), onSelectSuggestion = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt index 789a027cf7..010aff5d4b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -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.RoomMembershipState 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.model.Suggestion 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. */ @Inject -class SuggestionsProcessor { +class SuggestionsProcessor( + private val slashCommandService: SlashCommandService, +) { /** * Process the suggestion. * @param suggestion The current suggestion input @@ -31,6 +34,7 @@ class SuggestionsProcessor { * @param roomAliasSuggestions The available room alias suggestions * @param currentUserId The current user id * @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 */ suspend fun process( @@ -39,6 +43,7 @@ class SuggestionsProcessor { roomAliasSuggestions: List, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, + isInThread: Boolean, ): List { suggestion ?: return emptyList() 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, is SuggestionType.Custom -> { // Clear suggestions diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 23bcbe99bd..4bb3471660 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -112,7 +112,7 @@ class ThreadedMessagesNode( this.timelineController = timelineController return presenterFactory.create( navigator = this, - composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + composerPresenter = messageComposerPresenterFactory.create(timelineController, this, isInThread = true), timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), // TODO add special processor for threaded timeline actionListPresenter = actionListPresenterFactory.create( @@ -136,6 +136,7 @@ class ThreadedMessagesNode( fun navigateToEditPoll(eventId: EventId) fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun navigateToDeveloperSettings() } override fun onBuilt() { @@ -233,10 +234,18 @@ class ThreadedMessagesNode( callback.handlePermalinkClick(permalinkData) } + override fun navigateToMember(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { callback.navigateToThread(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } + override fun close() = navigateUp() @Composable diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt index a1db09dfda..dc50fca2c3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -93,6 +93,7 @@ class DefaultMessagesEntryPointTest { override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() override fun navigateToRoom(roomId: RoomId) = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() } val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) val params = MessagesEntryPoint.Params(initialTarget) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 68d2cd824b..44d82f1a7c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -24,6 +24,8 @@ class FakeMessagesNavigator( private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List) -> 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 closeLambda: () -> Unit = { lambdaError() }, ) : MessagesNavigator { @@ -51,10 +53,18 @@ class FakeMessagesNavigator( onNavigateToRoomLambda(roomId, eventId, serverNames) } + override fun navigateToMember(userId: UserId) { + navigateToMemberLambda(userId) + } + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { onOpenThreadLambda(threadRootId, focusedEventId) } + override fun navigateToDeveloperSettings() { + navigateToDeveloperSettingsLambda() + } + override fun close() { closeLambda() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt new file mode 100644 index 0000000000..116a1cfb5d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -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 {} + 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 { } + 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 ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index e16236f109..7a2cc1110a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -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.FakeTextPillificationHelper 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.designsystem.utils.snackbar.SnackbarDispatcher 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.draft.ComposerDraft 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.TimelineException 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.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.mentions.ResolvedSuggestion @@ -144,6 +149,7 @@ class MessageComposerPresenterTest { assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() + assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -374,10 +380,13 @@ class MessageComposerPresenterTest { val presenter = createPresenter( room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, typingNoticeResult = { Result.success(Unit) } ), + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() @@ -409,10 +418,13 @@ class MessageComposerPresenterTest { isRichTextEditorEnabled = false, room = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, typingNoticeResult = { Result.success(Unit) } ), + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() @@ -602,7 +614,7 @@ class MessageComposerPresenterTest { @Test fun `present - reply message`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean, _: MsgType -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -633,7 +645,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .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( Composer( @@ -967,7 +979,12 @@ class MessageComposerPresenterTest { ) givenRoomInfo(aRoomInfo(isDirect = false)) } - val presenter = createPresenter(room) + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) presenter.test { val initialState = awaitItem() @@ -1086,13 +1103,13 @@ class MessageComposerPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - send messages with intentional mentions`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean, _: MsgType -> Result.success(Unit) } val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> Result.success(Unit) } - val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List -> + val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -1104,7 +1121,12 @@ class MessageComposerPresenterTest { liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) } ) - val presenter = createPresenter(room = room) + val presenter = createPresenter( + room = room, + slashCommandService = FakeSlashCommandService( + parseResult = { _, _, _ -> SlashCommand.NotACommand } + ), + ) presenter.test { val initialState = awaitFirstItem() @@ -1122,7 +1144,7 @@ class MessageComposerPresenterTest { advanceUntilIdle() 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 initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode())) @@ -1139,7 +1161,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .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 skipItems(1) @@ -1512,9 +1534,12 @@ class MessageComposerPresenterTest { 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, @@ -1545,9 +1570,10 @@ class MessageComposerPresenterTest { draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, - suggestionsProcessor = SuggestionsProcessor(), + suggestionsProcessor = SuggestionsProcessor(slashCommandService = slashCommandService), mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, notificationConversationService = notificationConversationService, + slashCommandService = slashCommandService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt index daba41fb3c..6283d7236a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -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.room.aRoomMember 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.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -27,10 +29,13 @@ import org.junit.Test class SuggestionsProcessorTest { private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, 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 suggestionsProcessor = SuggestionsProcessor() + private val suggestionsProcessor = SuggestionsProcessor( + slashCommandService = FakeSlashCommandService( + getSuggestionsResult = { _, _ -> emptyList() }, + ), + ) @Test fun `processing null suggestion will return empty suggestion`() = runTest { @@ -40,18 +45,59 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @Test - fun `processing Command will return empty suggestion`() = runTest { - val result = suggestionsProcessor.process( - suggestion = aCommandSuggestion, + fun `processing Command will return suggestions from the slash service`() = runTest { + val suggestionsProcessorWithCommand = SuggestionsProcessor( + 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())), roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, 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() } @@ -64,6 +110,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -76,6 +123,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -88,6 +136,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -100,6 +149,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -120,6 +170,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -149,6 +200,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -178,6 +230,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -198,6 +251,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -227,6 +281,7 @@ class SuggestionsProcessorTest { ), currentUserId = A_USER_ID, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -240,6 +295,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -257,6 +313,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = UserId("@alice:server.org"), canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -270,6 +327,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -283,6 +341,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEmpty() } @@ -296,6 +355,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -313,6 +373,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { true }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( @@ -331,6 +392,7 @@ class SuggestionsProcessorTest { roomAliasSuggestions = emptyList(), currentUserId = A_USER_ID_2, canSendRoomMention = { false }, + isInThread = false, ) assertThat(result).isEqualTo( listOf( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 034c952f3d..bc36766bac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -12,6 +12,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat 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.MsgType 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.A_UNIQUE_ID @@ -154,10 +155,10 @@ class TimelineControllerTest { @Test fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { - val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List -> + val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } - val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List -> + val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List, _: MsgType, _: Boolean -> Result.success(Unit) } val liveTimeline = FakeTimeline(name = "live").apply { diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index 5a59d9be8a..04b471a498 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -28,6 +28,9 @@ interface PreferencesEntryPoint : FeatureEntryPoint { @Parcelize data object NotificationTroubleshoot : InitialTarget + + @Parcelize + data object DeveloperSettings : InitialTarget } data class Params(val initialElement: InitialTarget) : NodeInputs diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt index 4348b33756..57c561400c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -34,4 +34,5 @@ internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications + PreferencesEntryPoint.InitialTarget.DeveloperSettings -> PreferencesFlowNode.NavTarget.DeveloperSettings } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index c646923c77..15718ca0f0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -192,7 +192,11 @@ class PreferencesFlowNode( } override fun onDone() { - backstack.pop() + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } } } createNode(buildContext, listOf(developerSettingsCallback)) diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 928c08324d..07e10c65ef 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -39,6 +39,7 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToGlobalNotificationSettings() + fun navigateToDeveloperSettings() fun navigateToRoom(roomId: RoomId, serverNames: List) fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index e1024c611f..c3ae902ba9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -388,6 +388,10 @@ class RoomDetailsFlowNode( override fun navigateToRoom(roomId: RoomId) { callback.navigateToRoom(roomId, emptyList()) } + + override fun navigateToDeveloperSettings() { + callback.navigateToDeveloperSettings() + } } return messagesEntryPoint.createNode( parentNode = this, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt index 5042f942b6..bcf25b2aac 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -69,6 +69,7 @@ class DefaultRoomDetailsEntryPointTest { } val callback = object : RoomDetailsEntryPoint.Callback { override fun navigateToGlobalNotificationSettings() = lambdaError() + override fun navigateToDeveloperSettings() = lambdaError() override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index fee1278fce..fd2c8ff194 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -76,7 +76,7 @@ class SharePresenterTest { fun `present - on room selected ok`() = runTest { val joinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, ) val matrixClient = FakeMatrixClient().apply { @@ -103,7 +103,7 @@ class SharePresenterTest { fun `present - send text ok`() = runTest { val joinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _ -> Result.success(Unit) } + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } }, ) val matrixClient = FakeMatrixClient().apply { diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 9fe21a10c3..a8e59e5c6d 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -169,4 +169,11 @@ enum class FeatureFlags( defaultValue = { 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, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt index 4ac480e064..462ec0535c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt @@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable { is Id -> roomId.value is Alias -> roomAlias.value } + + companion object { + fun from(id: String): RoomIdOrAlias? { + return when { + MatrixPatterns.isRoomId(id) -> Id(RoomId(id)) + MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id)) + else -> null + } + } + } } fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt index 306ab8354b..09ceaa4712 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt @@ -17,3 +17,18 @@ interface MxcTools { */ fun mxcUri2FilePath(mxcUri: String): String? } + +/** + * "mxc" scheme, including "://". So "mxc://". + */ +const val MATRIX_CONTENT_URI_SCHEME = "mxc://" + +/** + * Return true if the String starts with "mxc://". + */ +fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME) + +/** + * Remove the "mxc://" prefix. No op if the String is not a Mxc URL. + */ +fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt new file mode 100644 index 0000000000..b8d3933663 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt @@ -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, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 500d9f3191..fe73230dce 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -69,6 +69,8 @@ interface Timeline : AutoCloseable { body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, + asPlainText: Boolean = false, ): Result suspend fun editMessage( @@ -90,6 +92,7 @@ interface Timeline : AutoCloseable { htmlBody: String?, intentionalMentions: List, fromNotification: Boolean = false, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, ): Result suspend fun sendImage( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 3996155871..8a184311de 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineException @@ -271,8 +272,16 @@ class RustTimeline( body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType, + asPlainText: Boolean, ): Result = withContext(dispatcher) { - MessageEventContent.from(body, htmlBody, intentionalMentions).use { content -> + MessageEventContent.from( + body = body, + htmlBody = htmlBody, + intentionalMentions = intentionalMentions, + msgType = msgType, + asPlainText = asPlainText, + ).use { content -> runCatchingExceptions { inner.send(content) } @@ -337,9 +346,15 @@ class RustTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, + msgType: MsgType, ): Result = withContext(dispatcher) { runCatchingExceptions { - val msg = MessageEventContent.from(body, htmlBody, intentionalMentions) + val msg = MessageEventContent.from( + body = body, + htmlBody = htmlBody, + intentionalMentions = intentionalMentions, + msgType = msgType, + ) inner.sendReply( msg = msg, eventId = repliedToEventId.value, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt index 3e320116c6..f1c0019f17 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt @@ -9,20 +9,54 @@ package io.element.android.libraries.matrix.impl.util import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.MessageContent +import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation +import org.matrix.rustcomponents.sdk.TextMessageContent +import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage import org.matrix.rustcomponents.sdk.messageEventContentFromHtml +import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote /** * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions. */ object MessageEventContent { - fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation { - return if (htmlBody != null) { - messageEventContentFromHtml(body, htmlBody) - } else { - messageEventContentFromMarkdown(body) - }.withMentions(intentionalMentions.map()) + fun from( + body: String, + htmlBody: String?, + intentionalMentions: List, + msgType: MsgType = MsgType.MSG_TYPE_TEXT, + asPlainText: Boolean = false, + ): RoomMessageEventContentWithoutRelation { + return when { + asPlainText -> contentWithoutRelationFromMessage( + MessageContent( + msgType = MessageType.Text( + TextMessageContent( + body = body, + formatted = null, + ) + ), + body = body, + isEdited = false, + mentions = null, + ) + ) + htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) { + messageEventContentFromHtmlAsEmote(body, htmlBody) + } else { + messageEventContentFromHtml(body, htmlBody) + } + else -> if (msgType == MsgType.MSG_TYPE_EMOTE) { + messageEventContentFromMarkdownAsEmote(body) + } else { + messageEventContentFromMarkdown(body) + } + } + .withMentions(intentionalMentions.map()) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 4451de6276..fcc7057dbe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -64,7 +65,9 @@ class FakeTimeline( body: String, htmlBody: String?, intentionalMentions: List, - ) -> Result = { _, _, _ -> + msgType: MsgType, + asPlainText: Boolean, + ) -> Result = { _, _, _, _, _ -> lambdaError() } @@ -76,8 +79,10 @@ class FakeTimeline( body: String, htmlBody: String?, intentionalMentions: List, + msgType: MsgType, + asPlainText: Boolean, ): Result = simulateLongTask { - sendMessageLambda(body, htmlBody, intentionalMentions) + sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText) } var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result = { _, _ -> @@ -134,7 +139,8 @@ class FakeTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, - ) -> Result = { _, _, _, _, _ -> + msgType: MsgType, + ) -> Result = { _, _, _, _, _, _ -> lambdaError() } @@ -144,12 +150,14 @@ class FakeTimeline( htmlBody: String?, intentionalMentions: List, fromNotification: Boolean, + msgType: MsgType, ): Result = replyMessageLambda( repliedToEventId, body, htmlBody, intentionalMentions, fromNotification, + msgType, ) var sendImageLambda: ( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt index a52eb16b07..2cf666d92a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -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.room.IntentionalMention 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.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE @@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = - lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest { advanceUntilIdle() sendMessage.assertions() .isCalledOnce() - .with(value(A_MESSAGE), value(null), value(emptyList())) + .with( + value(A_MESSAGE), + value(null), + value(emptyList()), + value(MsgType.MSG_TYPE_TEXT), + value(false), + ) onNotifiableEventsReceivedResult.assertions() .isCalledOnce() replyMessage.assertions() @@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply blank message`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage } @@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest { @Test fun `Test send reply to thread`() = runTest { - val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } val replyMessage = - lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) } val liveTimeline = FakeTimeline().apply { sendMessageLambda = sendMessage replyMessageLambda = replyMessage @@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest { value(A_MESSAGE), value(null), value(emptyList()), - value(true) + value(true), + value(MsgType.MSG_TYPE_TEXT), ) } diff --git a/libraries/slashcommands/api/build.gradle.kts b/libraries/slashcommands/api/build.gradle.kts new file mode 100644 index 0000000000..8cec0e65af --- /dev/null +++ b/libraries/slashcommands/api/build.gradle.kts @@ -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) +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt new file mode 100644 index 0000000000..7b31ffb3b7 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt @@ -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 +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt new file mode 100644 index 0000000000..713458c720 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt @@ -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, +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt new file mode 100644 index 0000000000..770543e548 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt @@ -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 +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt new file mode 100644 index 0000000000..9dfca26078 --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt @@ -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 + + /** + * 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 + + /** + * Proceed a SlashCommandAdmin. + */ + suspend fun proceedAdmin( + slashCommand: SlashCommand.SlashCommandAdmin, + ): Result +} diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt new file mode 100644 index 0000000000..5a826d5fbd --- /dev/null +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt @@ -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, +) diff --git a/libraries/slashcommands/impl/build.gradle.kts b/libraries/slashcommands/impl/build.gradle.kts new file mode 100644 index 0000000000..34dc2e42b2 --- /dev/null +++ b/libraries/slashcommands/impl/build.gradle.kts @@ -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) +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt new file mode 100644 index 0000000000..0b7b58a15f --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt @@ -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? = 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 = "", + description = R.string.slash_command_description_emote, + ), + BAN_USER( + command = "/ban", + parameters = " [reason]", + description = R.string.slash_command_description_ban_user, + ), + UNBAN_USER( + command = "/unban", + parameters = " [reason]", + description = R.string.slash_command_description_unban_user, + ), + IGNORE_USER( + command = "/ignore", + parameters = " [reason]", + description = R.string.slash_command_description_ignore_user, + ), + UNIGNORE_USER( + command = "/unignore", + parameters = "", + description = R.string.slash_command_description_unignore_user, + ), + SET_USER_POWER_LEVEL( + command = "/op", + parameters = " []", + description = R.string.slash_command_description_op_user, + isAllowedInThread = false, + isSupported = false, + ), + RESET_USER_POWER_LEVEL( + command = "/deop", + parameters = "", + description = R.string.slash_command_description_deop_user, + isAllowedInThread = false, + isSupported = false, + ), + ROOM_NAME( + command = "/roomname", + parameters = "", + description = R.string.slash_command_description_room_name, + isAllowedInThread = false, + ), + INVITE( + command = "/invite", + parameters = " [reason]", + description = R.string.slash_command_description_invite_user, + ), + JOIN_ROOM( + command = "/join", + aliases = listOf("/j", "/goto"), + parameters = " [reason]", + description = R.string.slash_command_description_join_room, + isAllowedInThread = false, + isSupported = false, + ), + TOPIC( + command = "/topic", + parameters = "", + description = R.string.slash_command_description_topic, + isAllowedInThread = false, + ), + REMOVE_USER( + command = "/remove", + aliases = listOf("/kick"), + parameters = " [reason]", + description = R.string.slash_command_description_remove_user, + ), + CHANGE_DISPLAY_NAME( + command = "/nick", + parameters = "", + description = R.string.slash_command_description_nick, + ), + CHANGE_DISPLAY_NAME_FOR_ROOM( + command = "/myroomnick", + aliases = listOf("/roomnick"), + parameters = "", + description = R.string.slash_command_description_nick_for_room, + isAllowedInThread = false, + isSupported = false, + ), + ROOM_AVATAR( + command = "/roomavatar", + parameters = "", + 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 = "", + 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 = "", + description = R.string.slash_command_description_rainbow, + ), + RAINBOW_EMOTE( + command = "/rainbowme", + parameters = "", + description = R.string.slash_command_description_rainbow_emote, + ), + DEVTOOLS( + command = "/devtools", + description = R.string.slash_command_description_devtools, + isDevCommand = true, + ), + SPOILER( + command = "/spoiler", + parameters = "", + description = R.string.slash_command_description_spoiler, + ), + SHRUG( + command = "/shrug", + parameters = "", + description = R.string.slash_command_description_shrug, + ), + LENNY( + command = "/lenny", + parameters = "", + description = R.string.slash_command_description_lenny, + ), + PLAIN( + command = "/plain", + parameters = "", + description = R.string.slash_command_description_plain, + ), + WHOIS( + command = "/whois", + parameters = "", + 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 = "", + description = R.string.slash_command_confetti, + isAllowedInThread = false, + isSupported = false, + ), + SNOWFALL( + command = "/snowfall", + parameters = "", + 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 = "", + description = R.string.slash_command_description_table_flip, + ), + UNFLIP( + command = "/unflip", + parameters = "", + 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) } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt new file mode 100644 index 0000000000..0acd3af6f8 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt @@ -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 { + 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 { + 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 { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result { + return matrixClient.unignoreUser(slashCommand.userId) + } + + private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result { + return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason) + } + + private fun setUserPowerLevel(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result { + val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})" + val formattedText = "${slashCommand.message}" + return timeline.sendMessage( + body = text, + htmlBody = formattedText, + intentionalMentions = emptyList(), + ) + } + + private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result { + 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 { + 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 { + return timeline.sendMessage( + body = slashCommand.message.toString(), + htmlBody = null, + intentionalMentions = emptyList(), + asPlainText = true, + ) + } + + private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result { + val message = slashCommand.message.toString() + return timeline.sendMessage( + body = message, + htmlBody = null, + msgType = MsgType.MSG_TYPE_EMOTE, + intentionalMentions = emptyList(), + ) + } + + private fun sendChatEffect(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result { + return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason) + } + + private suspend fun leaveRoom( + room: JoinedRoom, + ): Result { + return room.leave() + } + + private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result { + return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList()) + .map {} + } + + private suspend fun invite(slashCommand: SlashCommand.Invite): Result { + return joinedRoom.inviteUserById(slashCommand.userId) + } + + private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result { + return matrixClient.ignoreUser(slashCommand.userId) + } + + private fun discardSession(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result { + return joinedRoom.setTopic(slashCommand.topic) + } + + private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result { + return joinedRoom.setName(slashCommand.name) + } + + private fun changeRoomAvatar(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private fun changeDisplayNameForRoom(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result { + return matrixClient.setDisplayName(slashCommand.displayName) + } + + private fun changeAvatarForRoom(): Result { + return Result.failure(Exception("Not yet implemented")) + } + + private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result { + return joinedRoom.banUser(slashCommand.userId, slashCommand.reason) + } + + private suspend fun sendPrefixedMessage( + prefix: MessagePrefix, + message: CharSequence, + timeline: Timeline, + ): Result { + 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 -> "( ͡° ͜ʖ ͡°)" +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt new file mode 100644 index 0000000000..85a045f50c --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt @@ -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): UserId? { + val str = messageParts.getOrNull(1) ?: return null + return when { + MatrixPatterns.isUserId(str) -> str + str == " { + // Rich text editor mode + messageParts.getOrNull(2)?.let { html -> + // html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org" + val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)".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, 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 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? { + val partsSize = messageParts.sumOf { it.length } + val gapsNumber = messageParts.size - 1 + return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() } + } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt new file mode 100644 index 0000000000..ba2786c944 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandService.kt @@ -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 { + 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 { + return commandExecutor.proceedSendMessage( + slashCommand = slashCommand, + timeline = timeline, + ) + } + + override suspend fun proceedAdmin( + slashCommand: SlashCommand.SlashCommandAdmin, + ): Result { + return commandExecutor.proceedAdmin( + slashCommand = slashCommand, + ) + } +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt new file mode 100644 index 0000000000..594b51cbf6 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RainbowGenerator.kt @@ -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() + "$letter" + } + } + .joinToString(separator = "") + } + + private fun generateAB(hue: Double, chroma: Float): Pair { + 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 { + val result = mutableListOf() + 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 +} diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt new file mode 100644 index 0000000000..c425d81d73 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/rainbow/RgbColor.kt @@ -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') + } +} diff --git a/libraries/slashcommands/impl/src/main/res/values/temporary.xml b/libraries/slashcommands/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..0a8f2a0034 --- /dev/null +++ b/libraries/slashcommands/impl/src/main/res/values/temporary.xml @@ -0,0 +1,47 @@ + + + Command error + Unrecognized command: %1$s + The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s + The command \"%1$s\" is recognized but not supported in threads. + Displays action + Crash the application. + Bans user with given id + Unbans user with given id + Ignores a user, hiding their messages from you + Stops ignoring a user, showing their messages going forward + Define the power level of a user + Deops user with given id + Sets the room name + Sends the given message colored as a rainbow + Sends the given emote colored as a rainbow + Invites user with given id to current room + Joins room with given address + Sends the given message as a spoiler + Set the room topic + Removes user with given id from this room + Changes your display nickname + Sends the given message with confetti + Sends the given message with snowfall + Sends a message as plain text, without interpreting it as markdown + Changes your display nickname in the current room only + Changes the avatar of the current room + Changes your avatar in this current room only + Open the developer tools screen + Displays information about a user + Prepends ¯\\_(ツ)_/¯ to a plain-text message + Prepends ( ͡° ͜ʖ ͡°) to a plain-text message + Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message + Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message + Forces the current outbound group session in an encrypted room to be discarded + Only supported in encrypted rooms + Leave the current room + Upgrades a room to a new version + + Spoiler + diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt new file mode 100644 index 0000000000..497f45c96f --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutorTest.kt @@ -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(" + 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(" + 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("secret") + } + + @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, +) diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt new file mode 100644 index 0000000000..0887847a40 --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/CommandParserTest.kt @@ -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 ")) + } + + @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 [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 ")) + + test("/nick John", SlashCommand.ChangeDisplayName("John")) + test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick ")) + } + + @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 ")) + 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 ")) + + test("/rainbow yay", SlashCommand.SendRainbow("yay")) + test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow ")) + + test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay")) + test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme ")) + } + + @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 [reason]")) + + test("/roomname My Room", SlashCommand.ChangeRoomName("My Room")) + test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname ")) + } + + @Test + fun parseSlashCommandInviteBanEtc() = runTest { + test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null)) + test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite [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 []")) + 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 ")) + } + + @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, +) diff --git a/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt new file mode 100644 index 0000000000..243f25666c --- /dev/null +++ b/libraries/slashcommands/impl/src/test/kotlin/io/element/android/libraries/slashcommands/impl/DefaultSlashCommandServiceTest.kt @@ -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, _: 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.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, + ) +} diff --git a/libraries/slashcommands/test/build.gradle.kts b/libraries/slashcommands/test/build.gradle.kts new file mode 100644 index 0000000000..d8a54aa180 --- /dev/null +++ b/libraries/slashcommands/test/build.gradle.kts @@ -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) +} diff --git a/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt b/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt new file mode 100644 index 0000000000..319a8e647c --- /dev/null +++ b/libraries/slashcommands/test/src/main/kotlin/io/element/android/libraries/slashcommands/test/FakeSlashCommandService.kt @@ -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 = { _, _ -> lambdaError() }, + private val parseResult: (CharSequence, String?, Boolean) -> SlashCommand = { _, _, _ -> lambdaError() }, + private val proceedSendMessageResult: (SlashCommand.SlashCommandSendMessage, Timeline) -> Result = { _, _ -> lambdaError() }, + private val proceedAdminResult: (SlashCommand.SlashCommandAdmin) -> Result = { lambdaError() }, +) : SlashCommandService { + override suspend fun getSuggestions(text: String, isInThread: Boolean): List = 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 = simulateLongTask { + proceedSendMessageResult(slashCommand, timeline) + } + + override suspend fun proceedAdmin(slashCommand: SlashCommand.SlashCommandAdmin): Result = simulateLongTask { + proceedAdminResult(slashCommand) + } +} diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index a339890201..41e20582e0 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) + implementation(projects.libraries.slashcommands.api) releaseApi(libs.matrix.richtexteditor) releaseApi(libs.matrix.richtexteditor.compose) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt index d91735fb83..f9fd6a2b6d 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion @Immutable sealed interface ResolvedSuggestion { @@ -32,4 +33,8 @@ sealed interface ResolvedSuggestion { size = size, ) } + + data class Command( + val command: SlashCommandSuggestion, + ) : ResolvedSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index ba7e3c50c0..90e5368951 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -61,21 +61,29 @@ class MarkdownTextEditorState( } is ResolvedSuggestion.Member -> { val currentText = SpannableStringBuilder(text.value()) - val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId) + val userId = resolvedSuggestion.roomMember.userId + val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId) currentText.replace(suggestion.start, suggestion.end, "@ ") val end = suggestion.start + 1 currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - this.text.update(currentText, true) - this.selection = IntRange(end + 1, end + 1) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) } is ResolvedSuggestion.Alias -> { val currentText = SpannableStringBuilder(text.value()) - val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias()) + val roomAlias = resolvedSuggestion.roomAlias + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias()) currentText.replace(suggestion.start, suggestion.end, "# ") val end = suggestion.start + 1 currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - this.text.update(currentText, true) - this.selection = IntRange(end + 1, end + 1) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) + } + is ResolvedSuggestion.Command -> { + // Just insert the command text + text.update("${resolvedSuggestion.command.command} ", true) + val length = resolvedSuggestion.command.command.length + 1 + selection = IntRange(length, length) } } } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt index 04b700925e..6e57ce68cb 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionType @@ -42,6 +43,7 @@ class MarkdownTextEditorStateTest { val mentionSpanProvider = aMentionSpanProvider() state.insertSuggestion(suggestion, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() + assertThat(state.text.value().toString()).isEqualTo("Hello @") } @Test @@ -53,6 +55,7 @@ class MarkdownTextEditorStateTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("Hello # ") } @Test @@ -64,6 +67,19 @@ class MarkdownTextEditorStateTest { val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("Hello # ") + } + + @Test + fun `insertSuggestion - command`() { + val state = aMarkdownTextEditorState(initialText = "/rai", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 0, end = 3, type = SuggestionType.Command, text = "/rainbow") + } + val suggestion = aSlashCommandSuggestion() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.text.value().toString()).isEqualTo("/rainbow ") } @Test @@ -74,6 +90,7 @@ class MarkdownTextEditorStateTest { val mentionSpanProvider = aMentionSpanProvider() state.insertSuggestion(mention, mentionSpanProvider) assertThat(state.getMentions()).isEmpty() + assertThat(state.text.value().toString()).isEqualTo("Hello @") } @Test @@ -91,6 +108,7 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId) + assertThat(state.text.value().toString()).isEqualTo("Hello @ ") } @Test @@ -107,15 +125,14 @@ class MarkdownTextEditorStateTest { val mentions = state.getMentions() assertThat(mentions).isNotEmpty() assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java) + assertThat(state.text.value().toString()).isEqualTo("Hello @ ") } @Test fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() { val text = "No mentions here" val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) - val markdown = state.getMessageMarkdown(FakePermalinkBuilder()) - assertThat(markdown).isEqualTo(text) } @@ -128,19 +145,17 @@ class MarkdownTextEditorStateTest { ) val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) - val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) - assertThat(markdown).isEqualTo( "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + " and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)" ) + assertThat(state.text.value().toString()).isEqualTo("Hello @ and everyone in @ and a room #room:domain.org") } @Test fun `getMentions - when there are no MentionSpans returns empty list of mentions`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) - assertThat(state.getMentions()).isEmpty() } @@ -148,9 +163,7 @@ class MarkdownTextEditorStateTest { fun `getMentions - when there are MentionSpans returns a list of mentions`() { val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) - val mentions = state.getMentions() - assertThat(mentions).isNotEmpty() assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org") assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) @@ -184,4 +197,14 @@ class MarkdownTextEditorStateTest { roomAvatarUrl = null ) } + + private fun aSlashCommandSuggestion(): ResolvedSuggestion.Command { + return ResolvedSuggestion.Command( + command = SlashCommandSuggestion( + command = "/rainbow", + parameters = "param", + description = "Make the text colorful 🌈", + ), + ) + } } diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index e7cc47d7b8..8a5dfa2c4a 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -106,6 +106,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediaupload:impl")) + implementation(project(":libraries:slashcommands:impl")) implementation(project(":libraries:usersearch:impl")) implementation(project(":libraries:textcomposer:impl")) implementation(project(":libraries:accountselect:impl")) diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png index 6e14838915..986547c8c5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:31e730519a460ebc21ebee0d24c429ea22bceb164bd99080fe23b2c1c010577f -size 22488 +oid sha256:d2acf7cae297e8be76765b392dc07a36692a9e597b8f205de31a04dcf8b6bdca +size 37918 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png index b83a34aa0d..d333d2439d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:426d109d550f6298f9375c4a8406210b7d2c52a590678e5c21d4a0ac2864202d -size 22560 +oid sha256:47b4d158df0f83631d099c072b8b3d95a57d539fb9e8d7e6802f6a90a3b78284 +size 37904