From eae8f993e642226bc4f7c9c565d41c368e7d0751 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 9 Apr 2019 13:36:33 +0200 Subject: [PATCH] SlashCommand: implement parser --- .../riotredesign/features/command/Command.kt | 5 + .../features/command/CommandParser.kt | 171 ++++++++++++++++++ .../features/command/ParsedCommand.kt | 48 +++++ .../home/room/detail/RoomDetailFragment.kt | 34 +++- .../home/room/detail/RoomDetailViewModel.kt | 50 ++++- .../home/room/detail/SendMessageResult.kt | 28 +++ 6 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotredesign/features/command/ParsedCommand.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/home/room/detail/SendMessageResult.kt diff --git a/vector/src/main/java/im/vector/riotredesign/features/command/Command.kt b/vector/src/main/java/im/vector/riotredesign/features/command/Command.kt index e4955526056..a41a3afd420 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/command/Command.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/command/Command.kt @@ -19,6 +19,11 @@ package im.vector.riotredesign.features.command import androidx.annotation.StringRes import im.vector.riotredesign.R +/** + * Defines the command line operations + * the user can write theses messages to perform some actions + * the list will be displayed in this order + */ enum class Command(val command: String, val parameters: String, @StringRes val description: Int) { EMOTE("/me", "", R.string.command_description_emote), BAN_USER("/ban", " [reason]", R.string.command_description_ban_user), diff --git a/vector/src/main/java/im/vector/riotredesign/features/command/CommandParser.kt b/vector/src/main/java/im/vector/riotredesign/features/command/CommandParser.kt index ea508e42363..8a301b66bc6 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/command/CommandParser.kt @@ -16,5 +16,176 @@ package im.vector.riotredesign.features.command +import timber.log.Timber + object CommandParser { + + /** + * Convert the text message into a Slash command. + * + * @param textMessage the text message + * @return a parsed slash command (ok or error) + */ + fun parseSplashCommand(textMessage: String): ParsedCommand { + // check if it has the Slash marker + if (!textMessage.startsWith("/")) { + return ParsedCommand.ErrorNotACommand + } else { + Timber.d("parseSplashCommand") + + // "/" only + if (textMessage.length == 1) { + return ParsedCommand.ErrorEmptySlashCommand + } + + // Exclude "//" + if ("/" == textMessage.substring(1, 2)) { + return ParsedCommand.ErrorNotACommand + } + + var messageParts: List? = null + + try { + messageParts = textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } + } catch (e: Exception) { + Timber.e(e, "## manageSplashCommand() : split failed " + e.message) + } + + // test if the string cut fails + if (messageParts.isNullOrEmpty()) { + return ParsedCommand.ErrorEmptySlashCommand + } + + val slashCommand = messageParts[0] + + when (slashCommand) { + Command.CHANGE_DISPLAY_NAME.command -> { + val newDisplayname = textMessage.substring(Command.CHANGE_DISPLAY_NAME.command.length).trim() + + return if (newDisplayname.isNotEmpty()) { + ParsedCommand.ChangeDisplayName(newDisplayname) + } else { + ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME) + } + } + Command.TOPIC.command -> { + val newTopic = textMessage.substring(Command.TOPIC.command.length).trim() + + return if (newTopic.isNotEmpty()) { + ParsedCommand.ChangeTopic(newTopic) + } else { + ParsedCommand.ErrorSyntax(Command.TOPIC) + } + } + Command.EMOTE.command -> { + val message = textMessage.substring(Command.EMOTE.command.length).trim() + + return ParsedCommand.SendEmote(message) + } + Command.JOIN_ROOM.command -> { + val roomAlias = textMessage.substring(Command.JOIN_ROOM.command.length).trim() + + return if (roomAlias.isNotEmpty()) { + ParsedCommand.JoinRoom(roomAlias) + } else { + ParsedCommand.ErrorSyntax(Command.JOIN_ROOM) + } + } + Command.PART.command -> { + val roomAlias = textMessage.substring(Command.PART.command.length).trim() + + return if (roomAlias.isNotEmpty()) { + ParsedCommand.PartRoom(roomAlias) + } else { + ParsedCommand.ErrorSyntax(Command.PART) + } + } + Command.INVITE.command -> { + return if (messageParts.size == 2) { + ParsedCommand.Invite(messageParts[1]) + } else { + ParsedCommand.ErrorSyntax(Command.INVITE) + } + } + Command.KICK_USER.command -> { + return if (messageParts.size >= 2) { + val user = messageParts[1] + val reason = textMessage.substring(Command.KICK_USER.command.length + + 1 + + user.length).trim() + + ParsedCommand.KickUser(user, reason) + } else { + ParsedCommand.ErrorSyntax(Command.KICK_USER) + } + } + Command.BAN_USER.command -> { + return if (messageParts.size >= 2) { + val user = messageParts[1] + val reason = textMessage.substring(Command.BAN_USER.command.length + + 1 + + user.length).trim() + + ParsedCommand.BanUser(user, reason) + } else { + ParsedCommand.ErrorSyntax(Command.BAN_USER) + } + } + Command.UNBAN_USER.command -> { + return if (messageParts.size == 2) { + ParsedCommand.UnbanUser(messageParts[1]) + } else { + ParsedCommand.ErrorSyntax(Command.UNBAN_USER) + } + } + Command.SET_USER_POWER_LEVEL.command -> { + return if (messageParts.size == 3) { + val userID = messageParts[1] + val powerLevelsAsString = messageParts[2] + + try { + val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString) + + ParsedCommand.SetUserPowerLevel(userID, powerLevelsAsInt) + } catch (e: Exception) { + ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL) + } + } else { + ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL) + } + } + Command.RESET_USER_POWER_LEVEL.command -> { + return if (messageParts.size == 2) { + val userId = messageParts[1] + + ParsedCommand.SetUserPowerLevel(userId, 0) + } else { + ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL) + } + } + Command.MARKDOWN.command -> { + return if (messageParts.size == 2) { + when { + "on".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(true) + "off".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(false) + else -> ParsedCommand.ErrorSyntax(Command.MARKDOWN) + } + } else { + ParsedCommand.ErrorSyntax(Command.MARKDOWN) + } + } + Command.CLEAR_SCALAR_TOKEN.command -> { + return if (messageParts.size == 1) { + ParsedCommand.ClearSclalarToken + } else { + ParsedCommand.ErrorSyntax(Command.CLEAR_SCALAR_TOKEN) + } + } + else -> { + // Unknown command + return ParsedCommand.ErrorUnknownSlashCommand(slashCommand) + } + } + } + } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotredesign/features/command/ParsedCommand.kt new file mode 100644 index 00000000000..b0bb3ee3779 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/command/ParsedCommand.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.command + +/** + * Represent a parsed command + */ +sealed class ParsedCommand { + // This is not a Slash command + object ErrorNotACommand : ParsedCommand() + + object ErrorEmptySlashCommand : ParsedCommand() + + // Unknown/Unsupported slash command + class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand() + + // A slash command is detected, but there is an error + class ErrorSyntax(val command: Command) : ParsedCommand() + + // Valid commands: + + class SendEmote(val message: String) : ParsedCommand() + class BanUser(val userId: String, val reason: String) : ParsedCommand() + class UnbanUser(val userId: String) : ParsedCommand() + class SetUserPowerLevel(val userId: String, val powerLevel: Int) : ParsedCommand() + class Invite(val userId: String) : ParsedCommand() + class JoinRoom(val roomAlias: String) : ParsedCommand() + class PartRoom(val roomAlias: String) : ParsedCommand() + class ChangeTopic(val topic: String) : ParsedCommand() + class KickUser(val userId: String, val reason: String) : ParsedCommand() + class ChangeDisplayName(val displayName: String) : ParsedCommand() + class SetMarkdown(val enable: Boolean) : ParsedCommand() + object ClearSclalarToken : ParsedCommand() +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt index 82c2d7d55ff..13ab8eec53d 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt @@ -23,6 +23,7 @@ import android.os.Parcelable import android.text.Editable import android.text.Spannable import android.view.View +import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker @@ -35,9 +36,11 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.user.model.User import im.vector.riotredesign.R import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer +import im.vector.riotredesign.core.extensions.observeEvent import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.core.platform.VectorBaseFragment +import im.vector.riotredesign.core.utils.toast import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter @@ -80,7 +83,6 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac } private val session by inject() - // TODO Inject? private val glideRequests by lazy { GlideApp.with(this) } @@ -104,6 +106,7 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac setupComposer() roomDetailViewModel.subscribe { renderState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) } + roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } } override fun onResume() { @@ -192,8 +195,8 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac // Add the span val user = session.getUser(item.userId) - // FIXME avatar is not displayed val span = PillImageSpan(glideRequests, context!!, item.userId, user) + span.bind(composerEditText) editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -209,7 +212,6 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac val textMessage = composerEditText.text.toString() if (textMessage.isNotBlank()) { roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage)) - composerEditText.text = null } } } @@ -236,6 +238,32 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac autocompleteUserPresenter.render(state.asyncUsers) } + private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { + when (sendMessageResult) { + is SendMessageResult.MessageSent, is SendMessageResult.SlashCommandHandled -> { + // Clear composer + composerEditText.text = null + } + is SendMessageResult.SlashCommandError -> { + displayError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) + } + is SendMessageResult.SlashCommandUnknown -> { + displayError(getString(R.string.unrecognized_command, sendMessageResult.command)) + } + is SendMessageResult.SlashCommandNotImplemented -> { + activity!!.toast(R.string.not_implemented) + } + } + } + + private fun displayError(message: String) { + AlertDialog.Builder(activity!!) + .setTitle(R.string.command_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + // TimelineEventController.Callback ************************************************************ override fun onUrlClicked(url: String) { diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt index eeef99c0adc..ee9a591ecc1 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt @@ -16,6 +16,8 @@ package im.vector.riotredesign.features.home.room.detail +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.ViewModelContext import com.jakewharton.rxrelay2.BehaviorRelay @@ -24,6 +26,9 @@ import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.rx.rx import im.vector.riotredesign.core.platform.VectorViewModel +import im.vector.riotredesign.core.utils.LiveEvent +import im.vector.riotredesign.features.command.CommandParser +import im.vector.riotredesign.features.command.ParsedCommand import im.vector.riotredesign.features.home.room.VisibleRoomStore import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import io.reactivex.rxkotlin.subscribeBy @@ -63,17 +68,54 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, fun process(action: RoomDetailActions) { when (action) { - is RoomDetailActions.SendMessage -> handleSendMessage(action) - is RoomDetailActions.IsDisplayed -> handleIsDisplayed() + is RoomDetailActions.SendMessage -> handleSendMessage(action) + is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) - is RoomDetailActions.LoadMore -> handleLoadMore(action) + is RoomDetailActions.LoadMore -> handleLoadMore(action) } } + private val _sendMessageResultLiveData = MutableLiveData>() + val sendMessageResultLiveData: LiveData> + get() = _sendMessageResultLiveData + // PRIVATE METHODS ***************************************************************************** private fun handleSendMessage(action: RoomDetailActions.SendMessage) { - room.sendTextMessage(action.text, callback = object : MatrixCallback {}) + // Handle slash command + val slashCommandResult = CommandParser.parseSplashCommand(action.text) + + when (slashCommandResult) { + is ParsedCommand.ErrorNotACommand -> { + // Send the text message to the room + room.sendTextMessage(action.text, callback = object : MatrixCallback {}) + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) + } + is ParsedCommand.ErrorSyntax -> { + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))) + } + is ParsedCommand.ErrorEmptySlashCommand -> { + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/"))) + } + is ParsedCommand.ErrorUnknownSlashCommand -> { + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand))) + } + else -> { + handleValidSlashCommand(slashCommandResult) + } + } + } + + private fun handleValidSlashCommand(parsedCommand: ParsedCommand) { + when (parsedCommand) { + is ParsedCommand.Invite -> { + //room.invite(parsedCommand.userId) + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled)) + } + else -> { + _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) + } + } } private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/SendMessageResult.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/SendMessageResult.kt new file mode 100644 index 00000000000..8570e4fc220 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/SendMessageResult.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotredesign.features.home.room.detail + +import im.vector.riotredesign.features.command.Command + +sealed class SendMessageResult { + object MessageSent : SendMessageResult() + class SlashCommandError(val command: Command) : SendMessageResult() + class SlashCommandUnknown(val command: String) : SendMessageResult() + object SlashCommandHandled : SendMessageResult() + // TODO Remove + object SlashCommandNotImplemented : SendMessageResult() +} \ No newline at end of file