/* * 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.riotx.features.home.room.detail import android.net.Uri import android.text.TextUtils import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success import com.airbnb.mvrx.ViewModelContext import com.jakewharton.rxrelay2.BehaviorRelay import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.rx.rx import im.vector.riotx.core.intent.getFilenameFromUri import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.utils.LiveEvent import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import timber.log.Timber import java.io.File import java.util.concurrent.TimeUnit class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, userPreferencesProvider: UserPreferencesProvider, private val session: Session ) : VectorViewModel(initialState) { private val room = session.getRoom(initialState.roomId)!! private val roomId = initialState.roomId private val eventId = initialState.eventId private val displayedEventsObservable = BehaviorRelay.create() private val allowedTypes = if (userPreferencesProvider.shouldShowHiddenEvents()) { TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES } else { TimelineDisplayableEvents.DISPLAYABLE_TYPES } private var timeline = room.createTimeline(eventId, allowedTypes) // Slot to keep a pending action during permission request var pendingAction: RoomDetailActions? = null @AssistedInject.Factory interface Factory { fun create(initialState: RoomDetailViewState): RoomDetailViewModel } companion object : MvRxViewModelFactory { const val PAGINATION_COUNT = 50 @JvmStatic override fun create(viewModelContext: ViewModelContext, state: RoomDetailViewState): RoomDetailViewModel? { val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment() return fragment.roomDetailViewModelFactory.create(state) } } init { observeRoomSummary() observeEventDisplayedActions() observeInvitationState() cancelableBag += room.loadRoomMembersIfNeeded() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } } fun process(action: RoomDetailActions) { when (action) { is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMedia -> handleSendMedia(action) is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.LoadMore -> handleLoadMore(action) is RoomDetailActions.SendReaction -> handleSendReaction(action) is RoomDetailActions.AcceptInvite -> handleAcceptInvite() is RoomDetailActions.RejectInvite -> handleRejectInvite() is RoomDetailActions.RedactAction -> handleRedactEvent(action) is RoomDetailActions.UndoReaction -> handleUndoReact(action) is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action) is RoomDetailActions.EnterEditMode -> handleEditAction(action) is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) is RoomDetailActions.DownloadFile -> handleDownloadFile(action) is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) else -> Timber.e("Unhandled Action: $action") } } private fun enterEditMode(event: TimelineEvent) { setState { copy( sendMode = SendMode.EDIT(event) ) } } fun resetSendMode() { setState { copy( sendMode = SendMode.REGULAR ) } } private val _nonBlockingPopAlert = MutableLiveData>>>() val nonBlockingPopAlert: LiveData>>> get() = _nonBlockingPopAlert private val _sendMessageResultLiveData = MutableLiveData>() val sendMessageResultLiveData: LiveData> get() = _sendMessageResultLiveData private val _navigateToEvent = MutableLiveData>() val navigateToEvent: LiveData> get() = _navigateToEvent private val _downloadedFileEvent = MutableLiveData>() val downloadedFileEvent: LiveData> get() = _downloadedFileEvent // PRIVATE METHODS ***************************************************************************** private fun handleSendMessage(action: RoomDetailActions.SendMessage) { withState { state -> when (state.sendMode) { SendMode.REGULAR -> { val slashCommandResult = CommandParser.parseSplashCommand(action.text) when (slashCommandResult) { is ParsedCommand.ErrorNotACommand -> { // Send the text message to the room room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) _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))) } is ParsedCommand.Invite -> { handleInviteSlashCommand(slashCommandResult) } is ParsedCommand.SetUserPowerLevel -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.ClearScalarToken -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.SetMarkdown -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.UnbanUser -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.BanUser -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.KickUser -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.JoinRoom -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.PartRoom -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } is ParsedCommand.SendEmote -> { room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE) _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled)) } is ParsedCommand.ChangeTopic -> { handleChangeTopicSlashCommand(slashCommandResult) } is ParsedCommand.ChangeDisplayName -> { // TODO _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented)) } } } is SendMode.EDIT -> { //is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { //TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { room.editReply(state.sendMode.timelineEvent, it, action.text) } } else { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "", messageContent?.type ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) } else { Timber.w("Same message content, do not send edition") } } setState { copy( sendMode = SendMode.REGULAR ) } _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) } is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body val finalText = legacyRiotQuoteText(textMsg, action.text) //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 (TextUtils.equals(finalText, htmlText)) { room.sendTextMessage(finalText) } else { room.sendFormattedTextMessage(finalText, htmlText) } setState { copy( sendMode = SendMode.REGULAR ) } _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) } is SendMode.REPLY -> { state.sendMode.timelineEvent.let { room.replyToMessage(it, action.text, action.autoMarkdown) setState { copy( sendMode = SendMode.REGULAR ) } _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent)) } } } } // Handle slash command } private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() var quotedTextMsg = StringBuilder() if (messageParagraphs != null) { for (i in messageParagraphs.indices) { if (messageParagraphs[i].trim({ it <= ' ' }) != "") { quotedTextMsg.append("> ").append(messageParagraphs[i]) } if (i + 1 != messageParagraphs.size) { quotedTextMsg.append("\n\n") } } } val finalText = "$quotedTextMsg\n\n$myText" return finalText } private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled)) room.updateTopic(changeTopic.topic, object : MatrixCallback { override fun onSuccess(data: Unit) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultOk)) } override fun onFailure(failure: Throwable) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultError(failure))) } }) } private fun handleInviteSlashCommand(invite: ParsedCommand.Invite) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled)) room.invite(invite.userId, object : MatrixCallback { override fun onSuccess(data: Unit) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultOk)) } override fun onFailure(failure: Throwable) { _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultError(failure))) } }) } private fun handleSendReaction(action: RoomDetailActions.SendReaction) { room.sendReaction(action.reaction, action.targetEventId) } private fun handleRedactEvent(action: RoomDetailActions.RedactAction) { val event = room.getTimeLineEvent(action.targetEventId) ?: return room.redactEvent(event.root, action.reason) } private fun handleUndoReact(action: RoomDetailActions.UndoReaction) { room.undoReaction(action.key, action.targetEventId, session.myUserId) } private fun handleUpdateQuickReaction(action: RoomDetailActions.UpdateQuickReactAction) { if (action.add) { room.sendReaction(action.selectedReaction, action.targetEventId) } else { room.undoReaction(action.selectedReaction, action.targetEventId, session.myUserId) } } private fun handleSendMedia(action: RoomDetailActions.SendMedia) { val attachments = action.mediaFiles.map { val nameWithExtension = getFilenameFromUri(null, Uri.parse(it.path)) ContentAttachmentData( size = it.size, duration = it.duration, date = it.date, height = it.height, width = it.width, name = nameWithExtension ?: it.name, path = it.path, mimeType = it.mimeType, type = ContentAttachmentData.Type.values()[it.mediaType] ) } room.sendMedias(attachments) } private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { if (action.event.sendState.isSent()) { //ignore pending/local events displayedEventsObservable.accept(action) } //We need to update this with the related m.replace also (to move read receipt) action.event.annotations?.editSummary?.sourceEvents?.forEach { room.getTimeLineEvent(it)?.let { event -> displayedEventsObservable.accept(RoomDetailActions.EventDisplayed(event)) } } } private fun handleLoadMore(action: RoomDetailActions.LoadMore) { timeline.paginate(action.direction, PAGINATION_COUNT) } private fun handleRejectInvite() { room.leave(object : MatrixCallback {}) } private fun handleAcceptInvite() { room.join(object : MatrixCallback {}) } private fun handleEditAction(action: RoomDetailActions.EnterEditMode) { room.getTimeLineEvent(action.eventId)?.let { enterEditMode(it) } } private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) { room.getTimeLineEvent(action.eventId)?.let { setState { copy( sendMode = SendMode.QUOTE(it) ) } } } private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) { room.getTimeLineEvent(action.eventId)?.let { setState { copy( sendMode = SendMode.REPLY(it) ) } } } data class DownloadFileState( val mimeType: String, val file: File?, val throwable: Throwable? ) private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) { session.downloadFile( FileService.DownloadMode.TO_EXPORT, action.eventId, action.messageFileContent.getFileName(), action.messageFileContent.getFileUrl(), action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(), object : MatrixCallback { override fun onSuccess(data: File) { _downloadedFileEvent.postValue(LiveEvent(DownloadFileState( action.messageFileContent.getMimeType(), data, null ))) } override fun onFailure(failure: Throwable) { _downloadedFileEvent.postValue(LiveEvent(DownloadFileState( action.messageFileContent.getMimeType(), null, failure ))) } }) } private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) { val targetEventId = action.eventId if (action.position != null) { // Event is already in RAM withState { if (it.eventId == targetEventId) { // ensure another click on the same permalink will also do a scroll setState { copy( eventId = null ) } } setState { copy( eventId = targetEventId ) } } _navigateToEvent.postValue(LiveEvent(targetEventId)) } else { // change timeline timeline.dispose() timeline = room.createTimeline(targetEventId, allowedTypes) timeline.start() withState { if (it.eventId == targetEventId) { // ensure another click on the same permalink will also do a scroll setState { copy( eventId = null ) } } setState { copy( eventId = targetEventId, timeline = this@RoomDetailViewModel.timeline ) } } _navigateToEvent.postValue(LiveEvent(targetEventId)) } } private fun observeEventDisplayedActions() { // We are buffering scroll events for one second // and keep the most recent one to set the read receipt on. displayedEventsObservable .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> val mostRecentEvent = actions.maxBy { it.event.displayIndex } mostRecentEvent?.event?.root?.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) } }) .disposeOnClear() } private fun observeRoomSummary() { room.rx().liveRoomSummary() .execute { async -> copy( asyncRoomSummary = async, isEncrypted = room.isEncrypted() ) } } private fun observeInvitationState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> if (summary.membership == Membership.INVITE) { summary.latestEvent?.root?.senderId?.let { senderId -> session.getUser(senderId) }?.also { setState { copy(asyncInviter = Success(it)) } } } } } override fun onCleared() { timeline.dispose() super.onCleared() } }