Text composer: start extracting in a dedicated ViewModel/State/Action/Events

This commit is contained in:
ganfra 2021-09-28 18:54:48 +02:00
parent 7c2fac39f4
commit 9815dfe449
9 changed files with 770 additions and 589 deletions

View file

@ -30,10 +30,7 @@ import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
sealed class RoomDetailAction : VectorViewModelAction { 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 SendSticker(val stickerContent: MessageStickerContent) : RoomDetailAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction() data class SendMedia(val attachments: List<ContentAttachmentData>, val compressBeforeSending: Boolean) : RoomDetailAction()
data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction()
data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction()
@ -52,11 +49,6 @@ sealed class RoomDetailAction : VectorViewModelAction {
object EnterTrackingUnreadMessagesState : RoomDetailAction() object EnterTrackingUnreadMessagesState : RoomDetailAction()
object ExitTrackingUnreadMessagesState : 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 ResendMessage(val eventId: String) : RoomDetailAction()
data class RemoveFailedEcho(val eventId: String) : RoomDetailAction() data class RemoveFailedEcho(val eventId: String) : RoomDetailAction()
data class CancelSend(val eventId: String, val force: Boolean) : RoomDetailAction() data class CancelSend(val eventId: String, val force: Boolean) : RoomDetailAction()

View file

@ -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.keysbackup.restore.KeysBackupRestoreActivity
import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.home.AvatarRenderer 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.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.composer.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
@ -235,6 +239,7 @@ class RoomDetailFragment @Inject constructor(
private val permalinkHandler: PermalinkHandler, private val permalinkHandler: PermalinkHandler,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
val roomDetailViewModelFactory: RoomDetailViewModel.Factory, val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
val textComposerViewModelFactory: TextComposerViewModel.Factory,
private val eventHtmlRenderer: EventHtmlRenderer, private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
@ -287,6 +292,7 @@ class RoomDetailFragment @Inject constructor(
autoCompleterFactory.create(roomDetailArgs.roomId) autoCompleterFactory.create(roomDetailArgs.roomId)
} }
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel() private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
private val debouncer = Debouncer(createUIHandler()) private val debouncer = Debouncer(createUIHandler())
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
@ -379,7 +385,7 @@ class RoomDetailFragment @Inject constructor(
updateJumpToReadMarkerViewVisibility() updateJumpToReadMarkerViewVisibility()
} }
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode, RoomDetailViewState::canSendMessage) { mode, canSend -> textComposerViewModel.selectSubscribe(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend ->
if (!canSend) { if (!canSend) {
return@selectSubscribe return@selectSubscribe
} }
@ -391,6 +397,7 @@ class RoomDetailFragment @Inject constructor(
} }
} }
roomDetailViewModel.selectSubscribe( roomDetailViewModel.selectSubscribe(
RoomDetailViewState::syncState, RoomDetailViewState::syncState,
RoomDetailViewState::incrementalSyncStatus, 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 { roomDetailViewModel.observeViewEvents {
when (it) { when (it) {
is RoomDetailViewEvents.Failure -> { is RoomDetailViewEvents.Failure -> {
@ -418,8 +434,6 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message) is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode) is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager()
is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it)
@ -444,7 +458,6 @@ class RoomDetailFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
}.exhaustive }.exhaustive
} }
@ -495,7 +508,7 @@ class RoomDetailFragment @Inject constructor(
JoinReplacementRoomBottomSheet().show(childFragmentManager, tag) JoinReplacementRoomBottomSheet().show(childFragmentManager, tag)
} }
private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: RoomDetailViewEvents.ShowRoomUpgradeDialog) { private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: TextComposerViewEvents.ShowRoomUpgradeDialog) {
val tag = MigrateRoomBottomSheet::javaClass.name val tag = MigrateRoomBottomSheet::javaClass.name
MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion) MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion)
.show(parentFragmentManager, tag) .show(parentFragmentManager, tag)
@ -753,7 +766,7 @@ class RoomDetailFragment @Inject constructor(
.show() .show()
} }
private fun handleJoinedToAnotherRoom(action: RoomDetailViewEvents.JoinRoomCommandSuccess) { private fun handleJoinedToAnotherRoom(action: TextComposerViewEvents.JoinRoomCommandSuccess) {
updateComposerText("") updateComposerText("")
lockSendButton = false lockSendButton = false
navigator.openRoom(vectorBaseActivity, action.roomId) navigator.openRoom(vectorBaseActivity, action.roomId)
@ -762,7 +775,7 @@ class RoomDetailFragment @Inject constructor(
private fun handleShareData() { private fun handleShareData() {
when (val sharedData = roomDetailArgs.sharedData) { when (val sharedData = roomDetailArgs.sharedData) {
is SharedData.Text -> { is SharedData.Text -> {
roomDetailViewModel.handle(RoomDetailAction.EnterRegularMode(sharedData.text, fromSharing = true)) textComposerViewModel.handle(TextComposerAction.EnterRegularMode(sharedData.text, fromSharing = true))
} }
is SharedData.Attachments -> { is SharedData.Attachments -> {
// open share edition // open share edition
@ -980,9 +993,7 @@ class RoomDetailFragment @Inject constructor(
private fun renderRegularMode(text: String) { private fun renderRegularMode(text: String) {
autoCompleter.exitSpecialMode() autoCompleter.exitSpecialMode()
views.composerLayout.collapse() views.composerLayout.collapse()
views.voiceMessageRecorderView.isVisible = text.isBlank() views.voiceMessageRecorderView.isVisible = text.isBlank()
updateComposerText(text) updateComposerText(text)
views.composerLayout.views.sendButton.contentDescription = getString(R.string.send) views.composerLayout.views.sendButton.contentDescription = getString(R.string.send)
} }
@ -1077,7 +1088,7 @@ class RoomDetailFragment @Inject constructor(
notificationDrawerManager.setCurrentRoom(null) 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. // 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) roomDetailViewModel.handle(RoomDetailAction.EndAllVoiceActions)
@ -1196,12 +1207,12 @@ class RoomDetailFragment @Inject constructor(
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.attributes?.informationData?.let { (model as? AbsMessageItem)?.attributes?.informationData?.let {
val eventId = it.eventId 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 { override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
val canSendMessage = withState(roomDetailViewModel) { val canSendMessage = withState(textComposerViewModel) {
it.canSendMessage it.canSendMessage
} }
if (!canSendMessage) { if (!canSendMessage) {
@ -1303,7 +1314,7 @@ class RoomDetailFragment @Inject constructor(
} }
override fun onCloseRelatedMessage() { 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 { override fun onRichContentSelected(contentUri: Uri): Boolean {
@ -1320,6 +1331,7 @@ class RoomDetailFragment @Inject constructor(
views.voiceMessageRecorderView.isVisible = false views.voiceMessageRecorderView.isVisible = false
} }
} }
} }
} }
@ -1332,7 +1344,7 @@ class RoomDetailFragment @Inject constructor(
// We collapse ASAP, if not there will be a slight annoying delay // We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true) views.composerLayout.collapse(true)
lockSendButton = true lockSendButton = true
roomDetailViewModel.handle(RoomDetailAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) textComposerViewModel.handle(TextComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled()))
emojiPopup.dismiss() emojiPopup.dismiss()
} }
} }
@ -1344,7 +1356,7 @@ class RoomDetailFragment @Inject constructor(
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
.subscribe { .subscribe {
Timber.d("Typing: User is typing: $it") Timber.d("Typing: User is typing: $it")
roomDetailViewModel.handle(RoomDetailAction.UserIsTyping(it)) textComposerViewModel.handle(TextComposerAction.UserIsTyping(it))
} }
.disposeOnDestroyView() .disposeOnDestroyView()
@ -1364,24 +1376,24 @@ class RoomDetailFragment @Inject constructor(
return isHandled return isHandled
} }
override fun invalidate() = withState(roomDetailViewModel) { state -> override fun invalidate() = withState(roomDetailViewModel, textComposerViewModel) { mainState, textComposerState ->
invalidateOptionsMenu() invalidateOptionsMenu()
val summary = state.asyncRoomSummary() val summary = mainState.asyncRoomSummary()
renderToolbar(summary, state.typingMessage) renderToolbar(summary, mainState.formattedTypingUsers)
views.removeJitsiWidgetView.render(state) views.removeJitsiWidgetView.render(mainState)
if (state.hasFailedSending) { if (mainState.hasFailedSending) {
lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true
} else { } else {
lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = false)?.isVisible = false lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = false)?.isVisible = false
} }
val inviter = state.asyncInviter() val inviter = mainState.asyncInviter()
if (summary?.membership == Membership.JOIN) { if (summary?.membership == Membership.JOIN) {
views.jumpToBottomView.count = summary.notificationCount views.jumpToBottomView.count = summary.notificationCount
views.jumpToBottomView.drawBadge = summary.hasUnreadMessages views.jumpToBottomView.drawBadge = summary.hasUnreadMessages
timelineEventController.update(state) timelineEventController.update(mainState)
lazyLoadedViews.inviteView(false)?.isVisible = false lazyLoadedViews.inviteView(false)?.isVisible = false
if (state.tombstoneEvent == null) { if (mainState.tombstoneEvent == null) {
if (state.canSendMessage) { if (textComposerState.canSendMessage) {
if (!views.voiceMessageRecorderView.isActive()) { if (!views.voiceMessageRecorderView.isActive()) {
views.composerLayout.isVisible = true views.composerLayout.isVisible = true
views.voiceMessageRecorderView.isVisible = views.composerLayout.text?.isBlank().orFalse() views.voiceMessageRecorderView.isVisible = views.composerLayout.text?.isBlank().orFalse()
@ -1390,30 +1402,32 @@ class RoomDetailFragment @Inject constructor(
views.composerLayout.alwaysShowSendButton = false views.composerLayout.alwaysShowSendButton = false
} }
} else { } else {
views.composerLayout.isVisible = false views.hideComposerViews()
views.voiceMessageRecorderView.isVisible = false
views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost) views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost)
} }
} else { } else {
views.composerLayout.isVisible = false views.hideComposerViews()
views.voiceMessageRecorderView.isVisible = false views.notificationAreaView.render(NotificationAreaView.State.Tombstone(mainState.tombstoneEvent))
views.notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
} }
} else if (summary?.membership == Membership.INVITE && inviter != null) { } else if (summary?.membership == Membership.INVITE && inviter != null) {
views.composerLayout.isVisible = false views.hideComposerViews()
views.voiceMessageRecorderView.isVisible = false
lazyLoadedViews.inviteView(true)?.apply { lazyLoadedViews.inviteView(true)?.apply {
callback = this@RoomDetailFragment callback = this@RoomDetailFragment
isVisible = true isVisible = true
render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState) render(inviter, VectorInviteView.Mode.LARGE, mainState.changeMembershipState)
setOnClickListener { } setOnClickListener { }
} }
Unit Unit
} else if (state.asyncInviter.complete) { } else if (mainState.asyncInviter.complete) {
vectorBaseActivity.finish() vectorBaseActivity.finish()
} }
} }
private fun FragmentRoomDetailBinding.hideComposerViews() {
composerLayout.isVisible = false
voiceMessageRecorderView.isVisible = false
}
private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) { private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) {
if (roomSummary == null) { if (roomSummary == null) {
views.roomToolbarContentView.isClickable = false 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) { when (sendMessageResult) {
is RoomDetailViewEvents.SlashCommandHandled -> { is TextComposerViewEvents.SlashCommandHandled -> {
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
} }
is RoomDetailViewEvents.SlashCommandError -> { is TextComposerViewEvents.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) 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)) displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
} }
is RoomDetailViewEvents.SlashCommandResultOk -> { is TextComposerViewEvents.SlashCommandResultOk -> {
updateComposerText("") updateComposerText("")
} }
is RoomDetailViewEvents.SlashCommandResultError -> { is TextComposerViewEvents.SlashCommandResultError -> {
displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))
} }
is RoomDetailViewEvents.SlashCommandNotImplemented -> { is TextComposerViewEvents.SlashCommandNotImplemented -> {
displayCommandError(getString(R.string.not_implemented)) displayCommandError(getString(R.string.not_implemented))
} }
} // .exhaustive } // .exhaustive
@ -1938,17 +1952,17 @@ class RoomDetailFragment @Inject constructor(
} }
is EventSharedAction.Edit -> { is EventSharedAction.Edit -> {
if (!views.voiceMessageRecorderView.isActive()) { 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 { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
} }
} }
is EventSharedAction.Quote -> { 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 -> { is EventSharedAction.Reply -> {
if (!views.voiceMessageRecorderView.isActive()) { 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 { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
} }
@ -2160,7 +2174,7 @@ class RoomDetailFragment @Inject constructor(
override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { override fun onContactAttachmentReady(contactAttachment: ContactAttachment) {
super.onContactAttachmentReady(contactAttachment) super.onContactAttachmentReady(contactAttachment)
val formattedContact = contactAttachment.toHumanReadable() val formattedContact = contactAttachment.toHumanReadable()
roomDetailViewModel.handle(RoomDetailAction.SendMessage(formattedContact, false)) textComposerViewModel.handle(TextComposerAction.SendMessage(formattedContact, false))
} }
private fun onViewWidgetsClicked() { private fun onViewWidgetsClicked() {

View file

@ -66,8 +66,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
val mimeType: String? val mimeType: String?
) : RoomDetailViewEvents() ) : RoomDetailViewEvents()
abstract class SendMessageResult : RoomDetailViewEvents()
data class DisplayAndAcceptCall(val call: WebRtcCall): RoomDetailViewEvents() data class DisplayAndAcceptCall(val call: WebRtcCall): RoomDetailViewEvents()
object DisplayPromptForIntegrationManager : RoomDetailViewEvents() object DisplayPromptForIntegrationManager : RoomDetailViewEvents()
@ -82,19 +80,7 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
val domain: String, val domain: String,
val grantedEvents: RoomDetailViewEvents) : RoomDetailViewEvents() 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() data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents() object RoomReplacementStarted : RoomDetailViewEvents()
data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean): RoomDetailViewEvents()
} }

View file

@ -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.conference.JitsiService
import im.vector.app.features.call.lookup.CallProtocolsChecker import im.vector.app.features.call.lookup.CallProtocolsChecker
import im.vector.app.features.call.webrtc.WebRtcCallManager 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.createdirect.DirectRoomHelper
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
@ -67,9 +65,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.MatrixPatterns
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.query.QueryStringValue 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.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams 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.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.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary 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.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent 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.powerlevels.PowerLevelsHelper
import org.matrix.android.sdk.api.session.room.read.ReadService 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.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent 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.session.widgets.model.WidgetType
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode
@ -181,7 +168,6 @@ class RoomDetailViewModel @AssistedInject constructor(
observeSyncState() observeSyncState()
observeDataStore() observeDataStore()
observeEventDisplayedActions() observeEventDisplayedActions()
loadDraftIfAny()
observeUnreadState() observeUnreadState()
observeMyRoomMember() observeMyRoomMember()
observeActiveRoomWidgets() observeActiveRoomWidgets()
@ -235,13 +221,11 @@ class RoomDetailViewModel @AssistedInject constructor(
private fun observePowerLevel() { private fun observePowerLevel() {
PowerLevelsObservableFactory(room).createObservable() PowerLevelsObservableFactory(room).createObservable()
.subscribe { .subscribe {
val canSendMessage = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE)
val canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId) val canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId)
val isAllowedToManageWidgets = session.widgetService().hasPermissionsToHandleWidgets(room.roomId) val isAllowedToManageWidgets = session.widgetService().hasPermissionsToHandleWidgets(room.roomId)
val isAllowedToStartWebRTCCall = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.CALL_INVITE) val isAllowedToStartWebRTCCall = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.CALL_INVITE)
setState { setState {
copy( copy(
canSendMessage = canSendMessage,
canInvite = canInvite, canInvite = canInvite,
isAllowedToManageWidgets = isAllowedToManageWidgets, isAllowedToManageWidgets = isAllowedToManageWidgets,
isAllowedToStartWebRTCCall = isAllowedToStartWebRTCCall isAllowedToStartWebRTCCall = isAllowedToStartWebRTCCall
@ -300,10 +284,7 @@ class RoomDetailViewModel @AssistedInject constructor(
override fun handle(action: RoomDetailAction) { override fun handle(action: RoomDetailAction) {
when (action) { when (action) {
is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action)
is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action)
is RoomDetailAction.SaveDraft -> handleSaveDraft(action)
is RoomDetailAction.SendMessage -> handleSendMessage(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendMedia -> handleSendMedia(action)
is RoomDetailAction.SendSticker -> handleSendSticker(action) is RoomDetailAction.SendSticker -> handleSendSticker(action)
is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action)
@ -315,10 +296,6 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.RedactAction -> handleRedactEvent(action) is RoomDetailAction.RedactAction -> handleRedactEvent(action)
is RoomDetailAction.UndoReaction -> handleUndoReact(action) is RoomDetailAction.UndoReaction -> handleUndoReact(action)
is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(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.DownloadOrOpen -> handleOpenOrDownloadFile(action)
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailAction.JoinAndOpenReplacementRoom -> handleJoinAndOpenReplacementRoom() is RoomDetailAction.JoinAndOpenReplacementRoom -> handleJoinAndOpenReplacementRoom()
@ -590,70 +567,6 @@ class RoomDetailViewModel @AssistedInject constructor(
return room.getRoomMember(userId) 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) { private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) {
// Ensure outbound session keys // Ensure outbound session keys
if (OutboundSessionKeySharingStrategy.WhenTyping == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) { if (OutboundSessionKeySharingStrategy.WhenTyping == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) {
@ -768,416 +681,6 @@ class RoomDetailViewModel @AssistedInject constructor(
// PRIVATE METHODS ***************************************************************************** // 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})",
"<span data-mx-spoiler>${slashCommandResult.message}</span>"
)
_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<PowerLevelsContent>()
?.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>) -> Unit) {
_viewEvents.post(RoomDetailViewEvents.SlashCommandHandled())
val matrixCallback = object : MatrixCallback<Unit> {
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 fun handleSendReaction(action: RoomDetailAction.SendReaction) { private fun handleSendReaction(action: RoomDetailAction.SendReaction) {
room.sendReaction(action.targetEventId, action.reaction) 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) { private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
val mxcUrl = action.messageFileContent.getFileUrl() ?: return val mxcUrl = action.messageFileContent.getFileUrl() ?: return
val isLocalSendingFile = action.senderId == session.myUserId val isLocalSendingFile = action.senderId == session.myUserId
@ -1604,7 +1085,7 @@ class RoomDetailViewModel @AssistedInject constructor(
setState { setState {
val typingMessage = typingHelper.getTypingMessage(summary.typingUsers) val typingMessage = typingHelper.getTypingMessage(summary.typingUsers)
copy( copy(
typingMessage = typingMessage, formattedTypingUsers = typingMessage,
hasFailedSending = summary.hasFailedSending hasFailedSending = summary.hasFailedSending
) )
} }

View file

@ -73,7 +73,7 @@ data class RoomDetailViewState(
val asyncInviter: Async<RoomMemberSummary> = Uninitialized, val asyncInviter: Async<RoomMemberSummary> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized, val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val activeRoomWidgets: Async<List<Widget>> = Uninitialized, val activeRoomWidgets: Async<List<Widget>> = Uninitialized,
val typingMessage: String? = null, val formattedTypingUsers: String? = null,
val sendMode: SendMode = SendMode.REGULAR("", false), val sendMode: SendMode = SendMode.REGULAR("", false),
val tombstoneEvent: Event? = null, val tombstoneEvent: Event? = null,
val joinUpgradedRoomAsync: Async<String> = Uninitialized, val joinUpgradedRoomAsync: Async<String> = Uninitialized,
@ -84,7 +84,6 @@ data class RoomDetailViewState(
val unreadState: UnreadState = UnreadState.Unknown, val unreadState: UnreadState = UnreadState.Unknown,
val canShowJumpToReadMarker: Boolean = true, val canShowJumpToReadMarker: Boolean = true,
val changeMembershipState: ChangeMembershipState = ChangeMembershipState.Unknown, val changeMembershipState: ChangeMembershipState = ChangeMembershipState.Unknown,
val canSendMessage: Boolean = true,
val canInvite: Boolean = true, val canInvite: Boolean = true,
val isAllowedToManageWidgets: Boolean = false, val isAllowedToManageWidgets: Boolean = false,
val isAllowedToStartWebRTCCall: Boolean = true, val isAllowedToStartWebRTCCall: Boolean = true,

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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<TextComposerViewState, TextComposerAction, TextComposerViewEvents>(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})",
"<span data-mx-spoiler>${slashCommandResult.message}</span>"
)
_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<PowerLevelsContent>()
?.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<TextComposerViewModel, TextComposerViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: TextComposerViewState): TextComposerViewModel {
val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.textComposerViewModelFactory.create(state)
}
}
}

View file

@ -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,
)
}