diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 9bb82cdc27..88f172d040 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -30,10 +30,7 @@ import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.util.MatrixItem sealed class RoomDetailAction : VectorViewModelAction { - data class UserIsTyping(val isTyping: Boolean) : RoomDetailAction() - data class SaveDraft(val draft: String) : RoomDetailAction() data class SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction() - data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction() data class SendMedia(val attachments: List, val compressBeforeSending: Boolean) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction() @@ -52,11 +49,6 @@ sealed class RoomDetailAction : VectorViewModelAction { object EnterTrackingUnreadMessagesState : RoomDetailAction() object ExitTrackingUnreadMessagesState : RoomDetailAction() - data class EnterEditMode(val eventId: String, val text: String) : RoomDetailAction() - data class EnterQuoteMode(val eventId: String, val text: String) : RoomDetailAction() - data class EnterReplyMode(val eventId: String, val text: String) : RoomDetailAction() - data class EnterRegularMode(val text: String, val fromSharing: Boolean) : RoomDetailAction() - data class ResendMessage(val eventId: String) : RoomDetailAction() data class RemoveFailedEcho(val eventId: String) : RoomDetailAction() data class CancelSend(val eventId: String, val force: Boolean) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index c6eda584ad..520c0a3784 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -133,7 +133,11 @@ import im.vector.app.features.command.Command import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.composer.TextComposerAction import im.vector.app.features.home.room.detail.composer.TextComposerView +import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents +import im.vector.app.features.home.room.detail.composer.TextComposerViewModel +import im.vector.app.features.home.room.detail.composer.TextComposerViewState import im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.app.features.home.room.detail.timeline.TimelineEventController @@ -235,6 +239,7 @@ class RoomDetailFragment @Inject constructor( private val permalinkHandler: PermalinkHandler, private val notificationDrawerManager: NotificationDrawerManager, val roomDetailViewModelFactory: RoomDetailViewModel.Factory, + val textComposerViewModelFactory: TextComposerViewModel.Factory, private val eventHtmlRenderer: EventHtmlRenderer, private val vectorPreferences: VectorPreferences, private val colorProvider: ColorProvider, @@ -287,6 +292,7 @@ class RoomDetailFragment @Inject constructor( autoCompleterFactory.create(roomDetailArgs.roomId) } private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel() + private val textComposerViewModel: TextComposerViewModel by fragmentViewModel() private val debouncer = Debouncer(createUIHandler()) private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback @@ -379,7 +385,7 @@ class RoomDetailFragment @Inject constructor( updateJumpToReadMarkerViewVisibility() } - roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode, RoomDetailViewState::canSendMessage) { mode, canSend -> + textComposerViewModel.selectSubscribe(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend -> if (!canSend) { return@selectSubscribe } @@ -391,6 +397,7 @@ class RoomDetailFragment @Inject constructor( } } + roomDetailViewModel.selectSubscribe( RoomDetailViewState::syncState, RoomDetailViewState::incrementalSyncStatus, @@ -404,6 +411,15 @@ class RoomDetailFragment @Inject constructor( ) } + textComposerViewModel.observeViewEvents { + when(it){ + is TextComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) + is TextComposerViewEvents.SendMessageResult -> renderSendMessageResult(it) + is TextComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message) + is TextComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) + }.exhaustive + } + roomDetailViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> { @@ -418,8 +434,6 @@ class RoomDetailFragment @Inject constructor( is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message) is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) - is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) - is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode) RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) @@ -444,7 +458,6 @@ class RoomDetailFragment @Inject constructor( RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() - is RoomDetailViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) }.exhaustive } @@ -495,7 +508,7 @@ class RoomDetailFragment @Inject constructor( JoinReplacementRoomBottomSheet().show(childFragmentManager, tag) } - private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: RoomDetailViewEvents.ShowRoomUpgradeDialog) { + private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: TextComposerViewEvents.ShowRoomUpgradeDialog) { val tag = MigrateRoomBottomSheet::javaClass.name MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion) .show(parentFragmentManager, tag) @@ -753,7 +766,7 @@ class RoomDetailFragment @Inject constructor( .show() } - private fun handleJoinedToAnotherRoom(action: RoomDetailViewEvents.JoinRoomCommandSuccess) { + private fun handleJoinedToAnotherRoom(action: TextComposerViewEvents.JoinRoomCommandSuccess) { updateComposerText("") lockSendButton = false navigator.openRoom(vectorBaseActivity, action.roomId) @@ -762,7 +775,7 @@ class RoomDetailFragment @Inject constructor( private fun handleShareData() { when (val sharedData = roomDetailArgs.sharedData) { is SharedData.Text -> { - roomDetailViewModel.handle(RoomDetailAction.EnterRegularMode(sharedData.text, fromSharing = true)) + textComposerViewModel.handle(TextComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) } is SharedData.Attachments -> { // open share edition @@ -980,9 +993,7 @@ class RoomDetailFragment @Inject constructor( private fun renderRegularMode(text: String) { autoCompleter.exitSpecialMode() views.composerLayout.collapse() - views.voiceMessageRecorderView.isVisible = text.isBlank() - updateComposerText(text) views.composerLayout.views.sendButton.contentDescription = getString(R.string.send) } @@ -1077,7 +1088,7 @@ class RoomDetailFragment @Inject constructor( notificationDrawerManager.setCurrentRoom(null) - roomDetailViewModel.handle(RoomDetailAction.SaveDraft(views.composerLayout.text.toString())) + textComposerViewModel.handle(TextComposerAction.SaveDraft(views.composerLayout.text.toString())) // We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed. roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions) @@ -1196,12 +1207,12 @@ class RoomDetailFragment @Inject constructor( override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { (model as? AbsMessageItem)?.attributes?.informationData?.let { val eventId = it.eventId - roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(eventId, views.composerLayout.text.toString())) + textComposerViewModel.handle(TextComposerAction.EnterReplyMode(eventId, views.composerLayout.text.toString())) } } override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - val canSendMessage = withState(roomDetailViewModel) { + val canSendMessage = withState(textComposerViewModel) { it.canSendMessage } if (!canSendMessage) { @@ -1303,7 +1314,7 @@ class RoomDetailFragment @Inject constructor( } override fun onCloseRelatedMessage() { - roomDetailViewModel.handle(RoomDetailAction.EnterRegularMode(views.composerLayout.text.toString(), false)) + textComposerViewModel.handle(TextComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false)) } override fun onRichContentSelected(contentUri: Uri): Boolean { @@ -1320,6 +1331,7 @@ class RoomDetailFragment @Inject constructor( views.voiceMessageRecorderView.isVisible = false } } + } } @@ -1332,7 +1344,7 @@ class RoomDetailFragment @Inject constructor( // We collapse ASAP, if not there will be a slight annoying delay views.composerLayout.collapse(true) lockSendButton = true - roomDetailViewModel.handle(RoomDetailAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) + textComposerViewModel.handle(TextComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) emojiPopup.dismiss() } } @@ -1344,7 +1356,7 @@ class RoomDetailFragment @Inject constructor( .map { it.isNotEmpty() } .subscribe { Timber.d("Typing: User is typing: $it") - roomDetailViewModel.handle(RoomDetailAction.UserIsTyping(it)) + textComposerViewModel.handle(TextComposerAction.UserIsTyping(it)) } .disposeOnDestroyView() @@ -1364,24 +1376,24 @@ class RoomDetailFragment @Inject constructor( return isHandled } - override fun invalidate() = withState(roomDetailViewModel) { state -> + override fun invalidate() = withState(roomDetailViewModel, textComposerViewModel) { mainState, textComposerState -> invalidateOptionsMenu() - val summary = state.asyncRoomSummary() - renderToolbar(summary, state.typingMessage) - views.removeJitsiWidgetView.render(state) - if (state.hasFailedSending) { + val summary = mainState.asyncRoomSummary() + renderToolbar(summary, mainState.formattedTypingUsers) + views.removeJitsiWidgetView.render(mainState) + if (mainState.hasFailedSending) { lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true } else { lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = false)?.isVisible = false } - val inviter = state.asyncInviter() + val inviter = mainState.asyncInviter() if (summary?.membership == Membership.JOIN) { views.jumpToBottomView.count = summary.notificationCount views.jumpToBottomView.drawBadge = summary.hasUnreadMessages - timelineEventController.update(state) + timelineEventController.update(mainState) lazyLoadedViews.inviteView(false)?.isVisible = false - if (state.tombstoneEvent == null) { - if (state.canSendMessage) { + if (mainState.tombstoneEvent == null) { + if (textComposerState.canSendMessage) { if (!views.voiceMessageRecorderView.isActive()) { views.composerLayout.isVisible = true views.voiceMessageRecorderView.isVisible = views.composerLayout.text?.isBlank().orFalse() @@ -1390,30 +1402,32 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.alwaysShowSendButton = false } } else { - views.composerLayout.isVisible = false - views.voiceMessageRecorderView.isVisible = false + views.hideComposerViews() views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost) } } else { - views.composerLayout.isVisible = false - views.voiceMessageRecorderView.isVisible = false - views.notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) + views.hideComposerViews() + views.notificationAreaView.render(NotificationAreaView.State.Tombstone(mainState.tombstoneEvent)) } } else if (summary?.membership == Membership.INVITE && inviter != null) { - views.composerLayout.isVisible = false - views.voiceMessageRecorderView.isVisible = false + views.hideComposerViews() lazyLoadedViews.inviteView(true)?.apply { callback = this@RoomDetailFragment isVisible = true - render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState) + render(inviter, VectorInviteView.Mode.LARGE, mainState.changeMembershipState) setOnClickListener { } } Unit - } else if (state.asyncInviter.complete) { + } else if (mainState.asyncInviter.complete) { vectorBaseActivity.finish() } } + private fun FragmentRoomDetailBinding.hideComposerViews() { + composerLayout.isVisible = false + voiceMessageRecorderView.isVisible = false + } + private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) { if (roomSummary == null) { views.roomToolbarContentView.isClickable = false @@ -1442,24 +1456,24 @@ class RoomDetailFragment @Inject constructor( } } - private fun renderSendMessageResult(sendMessageResult: RoomDetailViewEvents.SendMessageResult) { + private fun renderSendMessageResult(sendMessageResult: TextComposerViewEvents.SendMessageResult) { when (sendMessageResult) { - is RoomDetailViewEvents.SlashCommandHandled -> { + is TextComposerViewEvents.SlashCommandHandled -> { sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } } - is RoomDetailViewEvents.SlashCommandError -> { + is TextComposerViewEvents.SlashCommandError -> { displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) } - is RoomDetailViewEvents.SlashCommandUnknown -> { + is TextComposerViewEvents.SlashCommandUnknown -> { displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) } - is RoomDetailViewEvents.SlashCommandResultOk -> { + is TextComposerViewEvents.SlashCommandResultOk -> { updateComposerText("") } - is RoomDetailViewEvents.SlashCommandResultError -> { + is TextComposerViewEvents.SlashCommandResultError -> { displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) } - is RoomDetailViewEvents.SlashCommandNotImplemented -> { + is TextComposerViewEvents.SlashCommandNotImplemented -> { displayCommandError(getString(R.string.not_implemented)) } } // .exhaustive @@ -1938,17 +1952,17 @@ class RoomDetailFragment @Inject constructor( } is EventSharedAction.Edit -> { if (!views.voiceMessageRecorderView.isActive()) { - roomDetailViewModel.handle(RoomDetailAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) + textComposerViewModel.handle(TextComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } is EventSharedAction.Quote -> { - roomDetailViewModel.handle(RoomDetailAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString())) + textComposerViewModel.handle(TextComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString())) } is EventSharedAction.Reply -> { if (!views.voiceMessageRecorderView.isActive()) { - roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) + textComposerViewModel.handle(TextComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } @@ -2160,7 +2174,7 @@ class RoomDetailFragment @Inject constructor( override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { super.onContactAttachmentReady(contactAttachment) val formattedContact = contactAttachment.toHumanReadable() - roomDetailViewModel.handle(RoomDetailAction.SendMessage(formattedContact, false)) + textComposerViewModel.handle(TextComposerAction.SendMessage(formattedContact, false)) } private fun onViewWidgetsClicked() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index 2802ee2f83..abd9a52e6b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -66,8 +66,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents { val mimeType: String? ) : RoomDetailViewEvents() - abstract class SendMessageResult : RoomDetailViewEvents() - data class DisplayAndAcceptCall(val call: WebRtcCall): RoomDetailViewEvents() object DisplayPromptForIntegrationManager : RoomDetailViewEvents() @@ -82,19 +80,7 @@ sealed class RoomDetailViewEvents : VectorViewEvents { val domain: String, val grantedEvents: RoomDetailViewEvents) : RoomDetailViewEvents() - object MessageSent : SendMessageResult() - data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult() - class SlashCommandError(val command: Command) : SendMessageResult() - class SlashCommandUnknown(val command: String) : SendMessageResult() - data class SlashCommandHandled(@StringRes val messageRes: Int? = null) : SendMessageResult() - object SlashCommandResultOk : SendMessageResult() - class SlashCommandResultError(val throwable: Throwable) : SendMessageResult() - - // TODO Remove - object SlashCommandNotImplemented : SendMessageResult() - data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents() object RoomReplacementStarted : RoomDetailViewEvents() - data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean): RoomDetailViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index cacf9b8902..013ace7aed 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -44,8 +44,6 @@ import im.vector.app.features.call.conference.JitsiActiveConferenceHolder import im.vector.app.features.call.conference.JitsiService import im.vector.app.features.call.lookup.CallProtocolsChecker import im.vector.app.features.call.webrtc.WebRtcCallManager -import im.vector.app.features.command.CommandParser -import im.vector.app.features.command.ParsedCommand import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider @@ -67,9 +65,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.query.QueryStringValue @@ -86,22 +81,14 @@ import org.matrix.android.sdk.api.session.initsync.SyncStatusService import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary -import org.matrix.android.sdk.api.session.room.model.message.MessageType -import org.matrix.android.sdk.api.session.room.model.message.OptionItem import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.read.ReadService -import org.matrix.android.sdk.api.session.room.send.UserDraft import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent -import org.matrix.android.sdk.api.session.room.timeline.getRelationContent -import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent -import org.matrix.android.sdk.api.session.space.CreateSpaceParams import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode @@ -181,7 +168,6 @@ class RoomDetailViewModel @AssistedInject constructor( observeSyncState() observeDataStore() observeEventDisplayedActions() - loadDraftIfAny() observeUnreadState() observeMyRoomMember() observeActiveRoomWidgets() @@ -235,13 +221,11 @@ class RoomDetailViewModel @AssistedInject constructor( private fun observePowerLevel() { PowerLevelsObservableFactory(room).createObservable() .subscribe { - val canSendMessage = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE) val canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId) val isAllowedToManageWidgets = session.widgetService().hasPermissionsToHandleWidgets(room.roomId) val isAllowedToStartWebRTCCall = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.CALL_INVITE) setState { copy( - canSendMessage = canSendMessage, canInvite = canInvite, isAllowedToManageWidgets = isAllowedToManageWidgets, isAllowedToStartWebRTCCall = isAllowedToStartWebRTCCall @@ -300,10 +284,7 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendSticker -> handleSendSticker(action) is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) @@ -315,10 +296,6 @@ class RoomDetailViewModel @AssistedInject constructor( is RoomDetailAction.RedactAction -> handleRedactEvent(action) is RoomDetailAction.UndoReaction -> handleUndoReact(action) is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailAction.JoinAndOpenReplacementRoom -> handleJoinAndOpenReplacementRoom() @@ -590,70 +567,6 @@ class RoomDetailViewModel @AssistedInject constructor( return room.getRoomMember(userId) } - /** - * Convert a send mode to a draft and save the draft - */ - private fun handleSaveDraft(action: RoomDetailAction.SaveDraft) = withState { - session.coroutineScope.launch { - when { - it.sendMode is SendMode.REGULAR && !it.sendMode.fromSharing -> { - setState { copy(sendMode = it.sendMode.copy(action.draft)) } - room.saveDraft(UserDraft.REGULAR(action.draft)) - } - it.sendMode is SendMode.REPLY -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, action.draft)) - } - it.sendMode is SendMode.QUOTE -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, action.draft)) - } - it.sendMode is SendMode.EDIT -> { - setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } - room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, action.draft)) - } - } - } - } - - private fun loadDraftIfAny() { - val currentDraft = room.getDraft() - setState { - copy( - // Create a sendMode from a draft and retrieve the TimelineEvent - sendMode = when (currentDraft) { - is UserDraft.REGULAR -> SendMode.REGULAR(currentDraft.text, false) - is UserDraft.QUOTE -> { - room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.QUOTE(timelineEvent, currentDraft.text) - } - } - is UserDraft.REPLY -> { - room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.REPLY(timelineEvent, currentDraft.text) - } - } - is UserDraft.EDIT -> { - room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> - SendMode.EDIT(timelineEvent, currentDraft.text) - } - } - else -> null - } ?: SendMode.REGULAR("", fromSharing = false) - ) - } - } - - private fun handleUserIsTyping(action: RoomDetailAction.UserIsTyping) { - if (vectorPreferences.sendTypingNotifs()) { - if (action.isTyping) { - room.userIsTyping() - } else { - room.userStopsTyping() - } - } - } - private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) { // Ensure outbound session keys if (OutboundSessionKeySharingStrategy.WhenTyping == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) { @@ -766,417 +679,7 @@ class RoomDetailViewModel @AssistedInject constructor( } } -// PRIVATE METHODS ***************************************************************************** - - private fun handleSendMessage(action: RoomDetailAction.SendMessage) { - withState { state -> - when (state.sendMode) { - is SendMode.REGULAR -> { - when (val slashCommandResult = CommandParser.parseSplashCommand(action.text)) { - is ParsedCommand.ErrorNotACommand -> { - // Send the text message to the room - room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) - _viewEvents.post(RoomDetailViewEvents.MessageSent) - popDraft() - } - is ParsedCommand.ErrorSyntax -> { - _viewEvents.post(RoomDetailViewEvents.SlashCommandError(slashCommandResult.command)) - } - is ParsedCommand.ErrorEmptySlashCommand -> { - _viewEvents.post(RoomDetailViewEvents.SlashCommandUnknown("/")) - } - is ParsedCommand.ErrorUnknownSlashCommand -> { - _viewEvents.post(RoomDetailViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand)) - } - is ParsedCommand.SendPlainText -> { - // Send the text message to the room, without markdown - room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) - _viewEvents.post(RoomDetailViewEvents.MessageSent) - popDraft() - } - is ParsedCommand.Invite -> { - handleInviteSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.Invite3Pid -> { - handleInvite3pidSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.SetUserPowerLevel -> { - handleSetUserPowerLevel(slashCommandResult) - popDraft() - } - is ParsedCommand.ClearScalarToken -> { - // TODO - _viewEvents.post(RoomDetailViewEvents.SlashCommandNotImplemented) - } - is ParsedCommand.SetMarkdown -> { - vectorPreferences.setMarkdownEnabled(slashCommandResult.enable) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled( - if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) - popDraft() - } - is ParsedCommand.UnbanUser -> { - handleUnbanSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.BanUser -> { - handleBanSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.KickUser -> { - handleKickSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.JoinRoom -> { - handleJoinToAnotherRoomSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.PartRoom -> { - // TODO - _viewEvents.post(RoomDetailViewEvents.SlashCommandNotImplemented) - } - is ParsedCommand.SendEmote -> { - room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendRainbow -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it)) - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendRainbowEmote -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it), MessageType.MSGTYPE_EMOTE) - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendSpoiler -> { - room.sendFormattedTextMessage( - "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})", - "${slashCommandResult.message}" - ) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendShrug -> { - val sequence = buildString { - append("¯\\_(ツ)_/¯") - if (slashCommandResult.message.isNotEmpty()) { - append(" ") - append(slashCommandResult.message) - } - } - room.sendTextMessage(sequence) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendChatEffect -> { - sendChatEffect(slashCommandResult) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.SendPoll -> { - room.sendPoll(slashCommandResult.question, slashCommandResult.options.mapIndexed { index, s -> OptionItem(s, "$index. $s") }) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.ChangeTopic -> { - handleChangeTopicSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.ChangeDisplayName -> { - handleChangeDisplayNameSlashCommand(slashCommandResult) - popDraft() - } - is ParsedCommand.DiscardSession -> { - if (room.isEncrypted()) { - session.cryptoService().discardOutboundSession(room.roomId) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } else { - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - _viewEvents.post( - RoomDetailViewEvents - .ShowMessage(stringProvider.getString(R.string.command_description_discard_session_not_handled)) - ) - } - } - is ParsedCommand.CreateSpace -> { - viewModelScope.launch(Dispatchers.IO) { - try { - val params = CreateSpaceParams().apply { - name = slashCommandResult.name - invitedUserIds.addAll(slashCommandResult.invitees) - } - val spaceId = session.spaceService().createSpace(params) - session.spaceService().getSpace(spaceId) - ?.addChildren( - state.roomId, - null, - null, - true - ) - } catch (failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - } - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.AddToSpace -> { - viewModelScope.launch(Dispatchers.IO) { - try { - session.spaceService().getSpace(slashCommandResult.spaceId) - ?.addChildren( - room.roomId, - null, - null, - false - ) - } catch (failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - } - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.JoinSpace -> { - viewModelScope.launch(Dispatchers.IO) { - try { - session.spaceService().joinSpace(slashCommandResult.spaceIdOrAlias) - } catch (failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - } - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.LeaveRoom -> { - viewModelScope.launch(Dispatchers.IO) { - try { - session.getRoom(slashCommandResult.roomId)?.leave(null) - } catch (failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - } - } - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - is ParsedCommand.UpgradeRoom -> { - _viewEvents.post( - RoomDetailViewEvents.ShowRoomUpgradeDialog( - slashCommandResult.newVersion, - room.roomSummary()?.isPublic ?: false - ) - ) - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - popDraft() - } - }.exhaustive - } - is SendMode.EDIT -> { - // is original event a reply? - val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId - if (inReplyTo != null) { - // TODO check if same content? - room.getTimeLineEvent(inReplyTo)?.let { - room.editReply(state.sendMode.timelineEvent, it, action.text.toString()) - } - } else { - val messageContent = state.sendMode.timelineEvent.getLastMessageContent() - val existingBody = messageContent?.body ?: "" - if (existingBody != action.text) { - room.editTextMessage(state.sendMode.timelineEvent, - messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, - action.text, - action.autoMarkdown) - } else { - Timber.w("Same message content, do not send edition") - } - } - _viewEvents.post(RoomDetailViewEvents.MessageSent) - popDraft() - } - is SendMode.QUOTE -> { - val messageContent = state.sendMode.timelineEvent.getLastMessageContent() - val textMsg = messageContent?.body - - val finalText = legacyRiotQuoteText(textMsg, action.text.toString()) - - // TODO check for pills? - - // TODO Refactor this, just temporary for quotes - val parser = Parser.builder().build() - val document = parser.parse(finalText) - val renderer = HtmlRenderer.builder().build() - val htmlText = renderer.render(document) - if (finalText == htmlText) { - room.sendTextMessage(finalText) - } else { - room.sendFormattedTextMessage(finalText, htmlText) - } - _viewEvents.post(RoomDetailViewEvents.MessageSent) - popDraft() - } - is SendMode.REPLY -> { - state.sendMode.timelineEvent.let { - room.replyToMessage(it, action.text.toString(), action.autoMarkdown) - _viewEvents.post(RoomDetailViewEvents.MessageSent) - popDraft() - } - } - }.exhaustive - } - } - - private fun sendChatEffect(sendChatEffect: ParsedCommand.SendChatEffect) { - // If message is blank, convert to an emote, with default message - if (sendChatEffect.message.isBlank()) { - val defaultMessage = stringProvider.getString(when (sendChatEffect.chatEffect) { - ChatEffect.CONFETTI -> R.string.default_message_emote_confetti - ChatEffect.SNOWFALL -> R.string.default_message_emote_snow - }) - room.sendTextMessage(defaultMessage, MessageType.MSGTYPE_EMOTE) - } else { - room.sendTextMessage(sendChatEffect.message, sendChatEffect.chatEffect.toMessageType()) - } - } - - private fun popDraft() = withState { - if (it.sendMode is SendMode.REGULAR && it.sendMode.fromSharing) { - // If we were sharing, we want to get back our last value from draft - loadDraftIfAny() - } else { - // Otherwise we clear the composer and remove the draft from db - setState { copy(sendMode = SendMode.REGULAR("", false)) } - viewModelScope.launch { - room.deleteDraft() - } - } - } - - private fun handleJoinToAnotherRoomSlashCommand(command: ParsedCommand.JoinRoom) { - viewModelScope.launch { - try { - session.joinRoom(command.roomAlias, command.reason, emptyList()) - } catch (failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - return@launch - } - session.getRoomSummary(command.roomAlias) - ?.roomId - ?.let { - _viewEvents.post(RoomDetailViewEvents.JoinRoomCommandSuccess(it)) - } - } - } - - private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { - val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() - return buildString { - if (messageParagraphs != null) { - for (i in messageParagraphs.indices) { - if (messageParagraphs[i].isNotBlank()) { - append("> ") - append(messageParagraphs[i]) - } - - if (i != messageParagraphs.lastIndex) { - append("\n\n") - } - } - } - append("\n\n") - append(myText) - } - } - - private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) { - launchSlashCommandFlowSuspendable { - room.updateTopic(changeTopic.topic) - } - } - - private fun handleInviteSlashCommand(invite: ParsedCommand.Invite) { - launchSlashCommandFlowSuspendable { - room.invite(invite.userId, invite.reason) - } - } - - private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) { - launchSlashCommandFlowSuspendable { - room.invite3pid(invite.threePid) - } - } - - private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) { - val newPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) - ?.content - ?.toModel() - ?.setUserPowerLevel(setUserPowerLevel.userId, setUserPowerLevel.powerLevel) - ?.toContent() - ?: return - - launchSlashCommandFlowSuspendable { - room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, null, newPowerLevelsContent) - } - } - - private fun handleChangeDisplayNameSlashCommand(changeDisplayName: ParsedCommand.ChangeDisplayName) { - launchSlashCommandFlowSuspendable { - session.setDisplayName(session.myUserId, changeDisplayName.displayName) - } - } - - private fun handleKickSlashCommand(kick: ParsedCommand.KickUser) { - launchSlashCommandFlowSuspendable { - room.kick(kick.userId, kick.reason) - } - } - - private fun handleBanSlashCommand(ban: ParsedCommand.BanUser) { - launchSlashCommandFlowSuspendable { - room.ban(ban.userId, ban.reason) - } - } - - private fun handleUnbanSlashCommand(unban: ParsedCommand.UnbanUser) { - launchSlashCommandFlowSuspendable { - room.unban(unban.userId, unban.reason) - } - } - - private fun launchSlashCommandFlow(lambda: (MatrixCallback) -> Unit) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - val matrixCallback = object : MatrixCallback { - override fun onSuccess(data: Unit) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultOk) - } - - override fun onFailure(failure: Throwable) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) - } - } - lambda.invoke(matrixCallback) - } - - private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) { - _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) - viewModelScope.launch { - val event = try { - block() - RoomDetailViewEvents.SlashCommandResultOk - } catch (failure: Exception) { - RoomDetailViewEvents.SlashCommandResultError(failure) - } - _viewEvents.post(event) - } - } + // PRIVATE METHODS ***************************************************************************** private fun handleSendReaction(action: RoomDetailAction.SendReaction) { room.sendReaction(action.targetEventId, action.reaction) @@ -1246,28 +749,6 @@ class RoomDetailViewModel @AssistedInject constructor( } } - private fun handleEditAction(action: RoomDetailAction.EnterEditMode) { - room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.EDIT(timelineEvent, timelineEvent.getTextEditableContent() ?: "")) } - } - } - - private fun handleQuoteAction(action: RoomDetailAction.EnterQuoteMode) { - room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.QUOTE(timelineEvent, action.text)) } - } - } - - private fun handleReplyAction(action: RoomDetailAction.EnterReplyMode) { - room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.REPLY(timelineEvent, action.text)) } - } - } - - private fun handleEnterRegularMode(action: RoomDetailAction.EnterRegularMode) = setState { - copy(sendMode = SendMode.REGULAR(action.text, action.fromSharing)) - } - private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) { val mxcUrl = action.messageFileContent.getFileUrl() ?: return val isLocalSendingFile = action.senderId == session.myUserId @@ -1604,7 +1085,7 @@ class RoomDetailViewModel @AssistedInject constructor( setState { val typingMessage = typingHelper.getTypingMessage(summary.typingUsers) copy( - typingMessage = typingMessage, + formattedTypingUsers = typingMessage, hasFailedSending = summary.hasFailedSending ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 8f4ad97b72..a1eac83fc8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -73,7 +73,7 @@ data class RoomDetailViewState( val asyncInviter: Async = Uninitialized, val asyncRoomSummary: Async = Uninitialized, val activeRoomWidgets: Async> = Uninitialized, - val typingMessage: String? = null, + val formattedTypingUsers: String? = null, val sendMode: SendMode = SendMode.REGULAR("", false), val tombstoneEvent: Event? = null, val joinUpgradedRoomAsync: Async = Uninitialized, @@ -84,7 +84,6 @@ data class RoomDetailViewState( val unreadState: UnreadState = UnreadState.Unknown, val canShowJumpToReadMarker: Boolean = true, val changeMembershipState: ChangeMembershipState = ChangeMembershipState.Unknown, - val canSendMessage: Boolean = true, val canInvite: Boolean = true, val isAllowedToManageWidgets: Boolean = false, val isAllowedToStartWebRTCCall: Boolean = true, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt new file mode 100644 index 0000000000..7896a009c1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 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.app.features.home.room.detail.composer + +import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.features.home.room.detail.RoomDetailAction + +sealed class TextComposerAction : VectorViewModelAction { + data class SaveDraft(val draft: String) : TextComposerAction() + data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : TextComposerAction() + data class EnterEditMode(val eventId: String, val text: String) : TextComposerAction() + data class EnterQuoteMode(val eventId: String, val text: String) : TextComposerAction() + data class EnterReplyMode(val eventId: String, val text: String) : TextComposerAction() + data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction() + data class UserIsTyping(val isTyping: Boolean) : TextComposerAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewEvents.kt new file mode 100644 index 0000000000..cce33a5b63 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewEvents.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 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.app.features.home.room.detail.composer + +import androidx.annotation.StringRes +import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.features.command.Command + +sealed class TextComposerViewEvents : VectorViewEvents { + + data class ShowMessage(val message: String) : TextComposerViewEvents() + + abstract class SendMessageResult : TextComposerViewEvents() + + object MessageSent : SendMessageResult() + data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult() + class SlashCommandError(val command: Command) : SendMessageResult() + class SlashCommandUnknown(val command: String) : SendMessageResult() + data class SlashCommandHandled(@StringRes val messageRes: Int? = null) : SendMessageResult() + object SlashCommandResultOk : SendMessageResult() + class SlashCommandResultError(val throwable: Throwable) : SendMessageResult() + + // TODO Remove + object SlashCommandNotImplemented : SendMessageResult() + + data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean) : TextComposerViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt new file mode 100644 index 0000000000..870f5682e0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewModel.kt @@ -0,0 +1,603 @@ +/* + * Copyright (c) 2021 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.app.features.home.room.detail.composer + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.BuildConfig +import im.vector.app.R +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.command.CommandParser +import im.vector.app.features.command.ParsedCommand +import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy +import im.vector.app.features.home.room.detail.ChatEffect +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.RoomDetailFragment +import im.vector.app.features.home.room.detail.SendMode +import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator +import im.vector.app.features.home.room.detail.toMessageType +import im.vector.app.features.powerlevel.PowerLevelsObservableFactory +import im.vector.app.features.session.coroutineScope +import im.vector.app.features.settings.VectorPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.message.OptionItem +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.api.session.room.send.UserDraft +import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent +import org.matrix.android.sdk.api.session.room.timeline.getRelationContent +import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent +import org.matrix.android.sdk.api.session.space.CreateSpaceParams +import timber.log.Timber + +class TextComposerViewModel @AssistedInject constructor( + @Assisted initialState: TextComposerViewState, + private val session: Session, + private val stringProvider: StringProvider, + private val vectorPreferences: VectorPreferences, + private val rainbowGenerator: RainbowGenerator +) : VectorViewModel(initialState) { + + private val room = session.getRoom(initialState.roomId)!! + + init { + loadDraftIfAny() + observePowerLevel() + } + + override fun handle(action: TextComposerAction) { + Timber.v("Handle action: $action") + when (action) { + is TextComposerAction.EnterEditMode -> handleEnterEditMode(action) + is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action) + is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action) + is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action) + is TextComposerAction.SaveDraft -> handleSaveDraft(action) + is TextComposerAction.SendMessage -> handleSendMessage(action) + is TextComposerAction.UserIsTyping -> handleUserIsTyping(action) + } + } + + private fun handleEnterRegularMode(action: TextComposerAction.EnterRegularMode) = setState { + copy(sendMode = SendMode.REGULAR(action.text, action.fromSharing)) + } + + private fun handleEnterEditMode(action: TextComposerAction.EnterEditMode) { + room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> + setState { copy(sendMode = SendMode.EDIT(timelineEvent, timelineEvent.getTextEditableContent() ?: "")) } + } + } + + private fun observePowerLevel() { + PowerLevelsObservableFactory(room).createObservable() + .subscribe { + val canSendMessage = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE) + setState { + copy(canSendMessage = canSendMessage) + } + } + .disposeOnClear() + } + + private fun handleEnterQuoteMode(action: TextComposerAction.EnterQuoteMode) { + room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> + setState { copy(sendMode = SendMode.QUOTE(timelineEvent, action.text)) } + } + } + + private fun handleEnterReplyMode(action: TextComposerAction.EnterReplyMode) { + room.getTimeLineEvent(action.eventId)?.let { timelineEvent -> + setState { copy(sendMode = SendMode.REPLY(timelineEvent, action.text)) } + } + } + + private fun handleSendMessage(action: TextComposerAction.SendMessage) { + withState { state -> + when (state.sendMode) { + is SendMode.REGULAR -> { + when (val slashCommandResult = CommandParser.parseSplashCommand(action.text)) { + is ParsedCommand.ErrorNotACommand -> { + // Send the text message to the room + room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + _viewEvents.post(TextComposerViewEvents.MessageSent) + popDraft() + } + is ParsedCommand.ErrorSyntax -> { + _viewEvents.post(TextComposerViewEvents.SlashCommandError(slashCommandResult.command)) + } + is ParsedCommand.ErrorEmptySlashCommand -> { + _viewEvents.post(TextComposerViewEvents.SlashCommandUnknown("/")) + } + is ParsedCommand.ErrorUnknownSlashCommand -> { + _viewEvents.post(TextComposerViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand)) + } + is ParsedCommand.SendPlainText -> { + // Send the text message to the room, without markdown + room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) + _viewEvents.post(TextComposerViewEvents.MessageSent) + popDraft() + } + is ParsedCommand.Invite -> { + handleInviteSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.Invite3Pid -> { + handleInvite3pidSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.SetUserPowerLevel -> { + handleSetUserPowerLevel(slashCommandResult) + popDraft() + } + is ParsedCommand.ClearScalarToken -> { + // TODO + _viewEvents.post(TextComposerViewEvents.SlashCommandNotImplemented) + } + is ParsedCommand.SetMarkdown -> { + vectorPreferences.setMarkdownEnabled(slashCommandResult.enable) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled( + if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) + popDraft() + } + is ParsedCommand.UnbanUser -> { + handleUnbanSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.BanUser -> { + handleBanSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.KickUser -> { + handleKickSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.JoinRoom -> { + handleJoinToAnotherRoomSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.PartRoom -> { + // TODO + _viewEvents.post(TextComposerViewEvents.SlashCommandNotImplemented) + } + is ParsedCommand.SendEmote -> { + room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendRainbow -> { + slashCommandResult.message.toString().let { + room.sendFormattedTextMessage(it, rainbowGenerator.generate(it)) + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendRainbowEmote -> { + slashCommandResult.message.toString().let { + room.sendFormattedTextMessage(it, rainbowGenerator.generate(it), MessageType.MSGTYPE_EMOTE) + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendSpoiler -> { + room.sendFormattedTextMessage( + "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})", + "${slashCommandResult.message}" + ) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendShrug -> { + val sequence = buildString { + append("¯\\_(ツ)_/¯") + if (slashCommandResult.message.isNotEmpty()) { + append(" ") + append(slashCommandResult.message) + } + } + room.sendTextMessage(sequence) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendChatEffect -> { + sendChatEffect(slashCommandResult) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.SendPoll -> { + room.sendPoll(slashCommandResult.question, slashCommandResult.options.mapIndexed { index, s -> OptionItem(s, "$index. $s") }) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.ChangeTopic -> { + handleChangeTopicSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.ChangeDisplayName -> { + handleChangeDisplayNameSlashCommand(slashCommandResult) + popDraft() + } + is ParsedCommand.DiscardSession -> { + if (room.isEncrypted()) { + session.cryptoService().discardOutboundSession(room.roomId) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } else { + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + _viewEvents.post( + TextComposerViewEvents + .ShowMessage(stringProvider.getString(R.string.command_description_discard_session_not_handled)) + ) + } + } + is ParsedCommand.CreateSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + val params = CreateSpaceParams().apply { + name = slashCommandResult.name + invitedUserIds.addAll(slashCommandResult.invitees) + } + val spaceId = session.spaceService().createSpace(params) + session.spaceService().getSpace(spaceId) + ?.addChildren( + state.roomId, + null, + null, + true + ) + } catch (failure: Throwable) { + _viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.AddToSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().getSpace(slashCommandResult.spaceId) + ?.addChildren( + room.roomId, + null, + null, + false + ) + } catch (failure: Throwable) { + _viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.JoinSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().joinSpace(slashCommandResult.spaceIdOrAlias) + } catch (failure: Throwable) { + _viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.LeaveRoom -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.getRoom(slashCommandResult.roomId)?.leave(null) + } catch (failure: Throwable) { + _viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.UpgradeRoom -> { + _viewEvents.post( + TextComposerViewEvents.ShowRoomUpgradeDialog( + slashCommandResult.newVersion, + room.roomSummary()?.isPublic ?: false + ) + ) + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + popDraft() + } + }.exhaustive + } + is SendMode.EDIT -> { + // is original event a reply? + val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId + if (inReplyTo != null) { + // TODO check if same content? + room.getTimeLineEvent(inReplyTo)?.let { + room.editReply(state.sendMode.timelineEvent, it, action.text.toString()) + } + } else { + val messageContent = state.sendMode.timelineEvent.getLastMessageContent() + val existingBody = messageContent?.body ?: "" + if (existingBody != action.text) { + room.editTextMessage(state.sendMode.timelineEvent, + messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, + action.text, + action.autoMarkdown) + } else { + Timber.w("Same message content, do not send edition") + } + } + _viewEvents.post(TextComposerViewEvents.MessageSent) + popDraft() + } + is SendMode.QUOTE -> { + val messageContent = state.sendMode.timelineEvent.getLastMessageContent() + val textMsg = messageContent?.body + + val finalText = legacyRiotQuoteText(textMsg, action.text.toString()) + + // TODO check for pills? + + // TODO Refactor this, just temporary for quotes + val parser = Parser.builder().build() + val document = parser.parse(finalText) + val renderer = HtmlRenderer.builder().build() + val htmlText = renderer.render(document) + if (finalText == htmlText) { + room.sendTextMessage(finalText) + } else { + room.sendFormattedTextMessage(finalText, htmlText) + } + _viewEvents.post(TextComposerViewEvents.MessageSent) + popDraft() + } + is SendMode.REPLY -> { + state.sendMode.timelineEvent.let { + room.replyToMessage(it, action.text.toString(), action.autoMarkdown) + _viewEvents.post(TextComposerViewEvents.MessageSent) + popDraft() + } + } + }.exhaustive + } + } + + private fun popDraft() = withState { + if (it.sendMode is SendMode.REGULAR && it.sendMode.fromSharing) { + // If we were sharing, we want to get back our last value from draft + loadDraftIfAny() + } else { + // Otherwise we clear the composer and remove the draft from db + setState { copy(sendMode = SendMode.REGULAR("", false)) } + viewModelScope.launch { + room.deleteDraft() + } + } + } + + private fun loadDraftIfAny() { + val currentDraft = room.getDraft() + setState { + copy( + // Create a sendMode from a draft and retrieve the TimelineEvent + sendMode = when (currentDraft) { + is UserDraft.REGULAR -> SendMode.REGULAR(currentDraft.text, false) + is UserDraft.QUOTE -> { + room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> + SendMode.QUOTE(timelineEvent, currentDraft.text) + } + } + is UserDraft.REPLY -> { + room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> + SendMode.REPLY(timelineEvent, currentDraft.text) + } + } + is UserDraft.EDIT -> { + room.getTimeLineEvent(currentDraft.linkedEventId)?.let { timelineEvent -> + SendMode.EDIT(timelineEvent, currentDraft.text) + } + } + else -> null + } ?: SendMode.REGULAR("", fromSharing = false) + ) + } + } + + private fun handleUserIsTyping(action: TextComposerAction.UserIsTyping) { + if (vectorPreferences.sendTypingNotifs()) { + if (action.isTyping) { + room.userIsTyping() + } else { + room.userStopsTyping() + } + } + } + + + private fun sendChatEffect(sendChatEffect: ParsedCommand.SendChatEffect) { + // If message is blank, convert to an emote, with default message + if (sendChatEffect.message.isBlank()) { + val defaultMessage = stringProvider.getString(when (sendChatEffect.chatEffect) { + ChatEffect.CONFETTI -> R.string.default_message_emote_confetti + ChatEffect.SNOWFALL -> R.string.default_message_emote_snow + }) + room.sendTextMessage(defaultMessage, MessageType.MSGTYPE_EMOTE) + } else { + room.sendTextMessage(sendChatEffect.message, sendChatEffect.chatEffect.toMessageType()) + } + } + + private fun handleJoinToAnotherRoomSlashCommand(command: ParsedCommand.JoinRoom) { + viewModelScope.launch { + try { + session.joinRoom(command.roomAlias, command.reason, emptyList()) + } catch (failure: Throwable) { + _viewEvents.post(TextComposerViewEvents.SlashCommandResultError(failure)) + return@launch + } + session.getRoomSummary(command.roomAlias) + ?.roomId + ?.let { + _viewEvents.post(TextComposerViewEvents.JoinRoomCommandSuccess(it)) + } + } + } + + private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { + val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() + return buildString { + if (messageParagraphs != null) { + for (i in messageParagraphs.indices) { + if (messageParagraphs[i].isNotBlank()) { + append("> ") + append(messageParagraphs[i]) + } + + if (i != messageParagraphs.lastIndex) { + append("\n\n") + } + } + } + append("\n\n") + append(myText) + } + } + + private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) { + launchSlashCommandFlowSuspendable { + room.updateTopic(changeTopic.topic) + } + } + + private fun handleInviteSlashCommand(invite: ParsedCommand.Invite) { + launchSlashCommandFlowSuspendable { + room.invite(invite.userId, invite.reason) + } + } + + private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) { + launchSlashCommandFlowSuspendable { + room.invite3pid(invite.threePid) + } + } + + private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) { + val newPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) + ?.content + ?.toModel() + ?.setUserPowerLevel(setUserPowerLevel.userId, setUserPowerLevel.powerLevel) + ?.toContent() + ?: return + + launchSlashCommandFlowSuspendable { + room.sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, null, newPowerLevelsContent) + } + } + + private fun handleChangeDisplayNameSlashCommand(changeDisplayName: ParsedCommand.ChangeDisplayName) { + launchSlashCommandFlowSuspendable { + session.setDisplayName(session.myUserId, changeDisplayName.displayName) + } + } + + private fun handleKickSlashCommand(kick: ParsedCommand.KickUser) { + launchSlashCommandFlowSuspendable { + room.kick(kick.userId, kick.reason) + } + } + + private fun handleBanSlashCommand(ban: ParsedCommand.BanUser) { + launchSlashCommandFlowSuspendable { + room.ban(ban.userId, ban.reason) + } + } + + private fun handleUnbanSlashCommand(unban: ParsedCommand.UnbanUser) { + launchSlashCommandFlowSuspendable { + room.unban(unban.userId, unban.reason) + } + } + + /** + * Convert a send mode to a draft and save the draft + */ + private fun handleSaveDraft(action: TextComposerAction.SaveDraft) = withState { + session.coroutineScope.launch { + when { + it.sendMode is SendMode.REGULAR && !it.sendMode.fromSharing -> { + setState { copy(sendMode = it.sendMode.copy(action.draft)) } + room.saveDraft(UserDraft.REGULAR(action.draft)) + } + it.sendMode is SendMode.REPLY -> { + setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } + room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + } + it.sendMode is SendMode.QUOTE -> { + setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } + room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + } + it.sendMode is SendMode.EDIT -> { + setState { copy(sendMode = it.sendMode.copy(text = action.draft)) } + room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, action.draft)) + } + } + } + } + + + private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) { + _viewEvents.post(TextComposerViewEvents.SlashCommandHandled()) + viewModelScope.launch { + val event = try { + block() + TextComposerViewEvents.SlashCommandResultOk + } catch (failure: Exception) { + TextComposerViewEvents.SlashCommandResultError(failure) + } + _viewEvents.post(event) + } + } + + @AssistedFactory + interface Factory { + fun create(initialState: TextComposerViewState): TextComposerViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: TextComposerViewState): TextComposerViewModel { + val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.textComposerViewModelFactory.create(state) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt new file mode 100644 index 0000000000..1e118ef38a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerViewState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021 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.app.features.home.room.detail.composer + +import com.airbnb.mvrx.MvRxState +import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.SendMode + +data class TextComposerViewState( + val roomId: String, + val canSendMessage: Boolean = true, + val isSendButtonVisible: Boolean = false, + val isRecordingVoiceMessage: Boolean = false, + val sendMode: SendMode = SendMode.REGULAR("", false), +) : MvRxState { + + constructor(args: RoomDetailArgs) : this( + roomId = args.roomId, + ) +} +