Merge pull request #4125 from vector-im/feature/fga/text_composer_extract

Feature/fga/text composer extract
This commit is contained in:
Benoit Marty 2021-10-01 14:44:51 +02:00 committed by GitHub
commit 8644363005
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 896 additions and 684 deletions

1
changelog.d/4077.bugfix Normal file
View File

@ -0,0 +1 @@
Fix Reply/Edit mode animation is broken when sending

View File

@ -18,6 +18,7 @@ package im.vector.app.core.extensions
import android.text.Editable
import android.text.InputType
import android.text.Spanned
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
@ -57,3 +58,38 @@ fun EditText.setupAsSearch(@DrawableRes searchIconRes: Int = R.drawable.ic_searc
return@OnTouchListener false
})
}
fun EditText.setTextIfDifferent(newText: CharSequence?): Boolean {
if (!isTextDifferent(newText, text)) {
// Previous text is the same. No op
return false
}
setText(newText)
// Since the text changed we move the cursor to the end of the new text.
// This allows us to fill in text programmatically with a different value,
// but if the user is typing and the view is rebound we won't lose their cursor position.
setSelection(newText?.length ?: 0)
return true
}
private fun isTextDifferent(str1: CharSequence?, str2: CharSequence?): Boolean {
if (str1 === str2) {
return false
}
if (str1 == null || str2 == null) {
return true
}
val length = str1.length
if (length != str2.length) {
return true
}
if (str1 is Spanned) {
return str1 != str2
}
for (i in 0 until length) {
if (str1[i] != str2[i]) {
return true
}
}
return false
}

View File

@ -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<ContentAttachmentData>, 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()

View File

@ -133,7 +133,12 @@ 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.SendMode
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
@ -187,7 +192,6 @@ import nl.dionsegijn.konfetti.models.Shape
import nl.dionsegijn.konfetti.models.Size
import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.toModel
@ -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
@ -375,11 +381,11 @@ class RoomDetailFragment @Inject constructor(
invalidateOptionsMenu()
}
roomDetailViewModel.selectSubscribe(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ ->
roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ ->
updateJumpToReadMarkerViewVisibility()
}
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode, RoomDetailViewState::canSendMessage) { mode, canSend ->
textComposerViewModel.selectSubscribe(this, TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend ->
if (!canSend) {
return@selectSubscribe
}
@ -392,6 +398,7 @@ class RoomDetailFragment @Inject constructor(
}
roomDetailViewModel.selectSubscribe(
this,
RoomDetailViewState::syncState,
RoomDetailViewState::incrementalSyncStatus,
RoomDetailViewState::pushCounter
@ -404,6 +411,16 @@ 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)
is TextComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it)
}.exhaustive
}
roomDetailViewModel.observeViewEvents {
when (it) {
is RoomDetailViewEvents.Failure -> {
@ -418,8 +435,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 +459,6 @@ class RoomDetailFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
}.exhaustive
}
@ -454,6 +468,20 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleSendButtonVisibilityChanged(event: TextComposerViewEvents.AnimateSendButtonVisibility) {
if (event.isVisible) {
views.voiceMessageRecorderView.isVisible = false
views.composerLayout.views.sendButton.alpha = 0f
views.composerLayout.views.sendButton.isVisible = true
views.composerLayout.views.sendButton.animate().alpha(1f).setDuration(150).start()
} else {
views.composerLayout.views.sendButton.isInvisible = true
views.voiceMessageRecorderView.alpha = 0f
views.voiceMessageRecorderView.isVisible = true
views.voiceMessageRecorderView.animate().alpha(1f).setDuration(150).start()
}
}
private fun setupRemoveJitsiWidgetView() {
views.removeJitsiWidgetView.onCompleteSliding = {
withState(roomDetailViewModel) {
@ -495,7 +523,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)
@ -656,8 +684,8 @@ class RoomDetailFragment @Inject constructor(
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted(): Boolean {
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
views.composerLayout.isInvisible = true
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
vibrate(requireContext())
true
} else {
@ -667,8 +695,8 @@ class RoomDetailFragment @Inject constructor(
}
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
views.composerLayout.isInvisible = false
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
}
override fun onVoiceRecordingPlaybackModeOn() {
@ -753,8 +781,8 @@ class RoomDetailFragment @Inject constructor(
.show()
}
private fun handleJoinedToAnotherRoom(action: RoomDetailViewEvents.JoinRoomCommandSuccess) {
updateComposerText("")
private fun handleJoinedToAnotherRoom(action: TextComposerViewEvents.JoinRoomCommandSuccess) {
views.composerLayout.setTextIfDifferent("")
lockSendButton = false
navigator.openRoom(vectorBaseActivity, action.roomId)
}
@ -762,7 +790,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,10 +1008,7 @@ class RoomDetailFragment @Inject constructor(
private fun renderRegularMode(text: String) {
autoCompleter.exitSpecialMode()
views.composerLayout.collapse()
views.voiceMessageRecorderView.isVisible = text.isBlank()
updateComposerText(text)
views.composerLayout.setTextIfDifferent(text)
views.composerLayout.views.sendButton.contentDescription = getString(R.string.send)
}
@ -1022,7 +1047,7 @@ class RoomDetailFragment @Inject constructor(
false
}
updateComposerText(defaultContent)
views.composerLayout.setTextIfDifferent(defaultContent)
views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes)
@ -1034,21 +1059,11 @@ class RoomDetailFragment @Inject constructor(
// need to do it here also when not using quick reply
focusComposerAndShowKeyboard()
views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible
views.voiceMessageRecorderView.isVisible = false
}
}
focusComposerAndShowKeyboard()
}
private fun updateComposerText(text: String) {
// Do not update if this is the same text to avoid the cursor to move
if (text != views.composerLayout.text.toString()) {
// Ignore update to avoid saving a draft
views.composerLayout.views.composerEditText.setText(text)
views.composerLayout.views.composerEditText.setSelection(views.composerLayout.text?.length ?: 0)
}
}
override fun onResume() {
super.onResume()
notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId)
@ -1077,7 +1092,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 +1211,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,22 +1318,15 @@ 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 {
return sendUri(contentUri)
}
override fun onTextBlankStateChanged(isBlank: Boolean) {
if (!views.composerLayout.views.sendButton.isVisible) {
// Animate alpha to prevent overlapping with the animation of the send button
views.voiceMessageRecorderView.alpha = 0f
views.voiceMessageRecorderView.isVisible = true
views.voiceMessageRecorderView.animate().alpha(1f).setDuration(300).start()
} else {
views.voiceMessageRecorderView.isVisible = false
}
override fun onTextChanged(text: CharSequence) {
textComposerViewModel.handle(TextComposerAction.OnTextChanged(text))
}
}
}
@ -1332,7 +1340,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 +1352,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,56 +1372,56 @@ 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 (!views.voiceMessageRecorderView.isActive()) {
views.composerLayout.isVisible = true
views.voiceMessageRecorderView.isVisible = views.composerLayout.text?.isBlank().orFalse()
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
views.composerLayout.alwaysShowSendButton = false
}
if (mainState.tombstoneEvent == null) {
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = !textComposerState.isSendButtonVisible
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
// views.composerLayout.alwaysShowSendButton = false
if (textComposerState.canSendMessage) {
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
} else {
views.composerLayout.isVisible = false
views.voiceMessageRecorderView.isVisible = false
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 +1450,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 -> {
updateComposerText("")
is TextComposerViewEvents.SlashCommandResultOk -> {
views.composerLayout.setTextIfDifferent("")
}
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 +1946,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 +2168,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() {

View File

@ -18,10 +18,8 @@ package im.vector.app.features.home.room.detail
import android.net.Uri
import android.view.View
import androidx.annotation.StringRes
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.command.Command
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode
@ -66,8 +64,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 +78,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()
}

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.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})",
"<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 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
)
}

View File

@ -25,32 +25,10 @@ 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.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
/**
* Describes the current send mode:
* REGULAR: sends the text as a regular message
* QUOTE: User is currently quoting a message
* EDIT: User is currently editing an existing message
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
sealed class SendMode(open val text: String) {
data class REGULAR(
override val text: String,
val fromSharing: Boolean,
// This is necessary for forcing refresh on selectSubscribe
private val ts: Long = System.currentTimeMillis()
) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
sealed class UnreadState {
object Unknown : UnreadState()
object HasNoUnread : UnreadState()
@ -73,8 +51,7 @@ data class RoomDetailViewState(
val asyncInviter: Async<RoomMemberSummary> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val activeRoomWidgets: Async<List<Widget>> = Uninitialized,
val typingMessage: String? = null,
val sendMode: SendMode = SendMode.REGULAR("", false),
val formattedTypingUsers: String? = null,
val tombstoneEvent: Event? = null,
val joinUpgradedRoomAsync: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.Idle,
@ -84,7 +61,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,

View File

@ -37,11 +37,10 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
interface Callback {
fun onRichContentSelected(contentUri: Uri): Boolean
fun onTextBlankStateChanged(isBlank: Boolean)
fun onTextChanged(text: CharSequence)
}
var callback: Callback? = null
private var isBlankText = true
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(editorInfo) ?: return null
@ -95,11 +94,7 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
}
spanToRemove = null
}
// Report blank status of EditText to be able to arrange other elements of the composer
if (s.isBlank() != isBlankText) {
isBlankText = !isBlankText
callback?.onTextBlankStateChanged(isBlankText)
}
callback?.onTextChanged(s.toString())
}
}
)

View File

@ -0,0 +1,31 @@
/*
* 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
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()
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
}

View File

@ -24,17 +24,14 @@ import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.text.toSpannable
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.transition.AutoTransition
import androidx.transition.ChangeBounds
import androidx.transition.Fade
import androidx.transition.Transition
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import im.vector.app.R
import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.databinding.ComposerLayoutBinding
import org.matrix.android.sdk.api.extensions.orFalse
/**
* Encapsulate the timeline composer UX.
@ -61,13 +58,6 @@ class TextComposerView @JvmOverloads constructor(
val text: Editable?
get() = views.composerEditText.text
var alwaysShowSendButton = false
set(value) {
field = value
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || text?.isNotBlank().orFalse() || value
views.sendButton.isInvisible = !shouldShowSendButton
}
init {
inflate(context, R.layout.composer_layout, this)
views = ComposerLayoutBinding.bind(this)
@ -79,17 +69,8 @@ class TextComposerView @JvmOverloads constructor(
return callback?.onRichContentSelected(contentUri) ?: false
}
override fun onTextBlankStateChanged(isBlank: Boolean) {
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || !isBlank || alwaysShowSendButton
TransitionManager.endTransitions(this@TextComposerView)
if (views.sendButton.isVisible != shouldShowSendButton) {
TransitionManager.beginDelayedTransition(
this@TextComposerView,
AutoTransition().also { it.duration = 150 }
)
views.sendButton.isInvisible = !shouldShowSendButton
}
callback?.onTextBlankStateChanged(isBlank)
override fun onTextChanged(text: CharSequence) {
callback?.onTextChanged(text)
}
}
views.composerRelatedMessageCloseButton.setOnClickListener {
@ -114,9 +95,6 @@ class TextComposerView @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete)
val shouldShowSendButton = !views.composerEditText.text.isNullOrEmpty() || alwaysShowSendButton
views.sendButton.isInvisible = !shouldShowSendButton
}
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
@ -126,10 +104,14 @@ class TextComposerView @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete)
views.sendButton.isInvisible = false
}
fun setTextIfDifferent(text: CharSequence?): Boolean {
return views.composerEditText.setTextIfDifferent(text)
}
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
// val wasSendButtonInvisible = views.sendButton.isInvisible
if (animate) {
configureAndBeginTransition(transitionComplete)
}
@ -137,6 +119,8 @@ class TextComposerView @JvmOverloads constructor(
it.clone(context, currentConstraintSetId)
it.applyTo(this)
}
// Might be updated by view state just after, but avoid blinks
// views.sendButton.isInvisible = wasSendButtonInvisible
}
private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) {

View File

@ -0,0 +1,43 @@
/*
* 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 AnimateSendButtonVisibility(val isVisible: Boolean): TextComposerViewEvents()
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,625 @@
/*
* 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.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.home.room.detail.ChatEffect
import im.vector.app.features.home.room.detail.RoomDetailFragment
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)!!
// Keep it out of state to avoid invalidate being called
private var currentComposerText: CharSequence = ""
init {
loadDraftIfAny()
observePowerLevel()
subscribeToStateInternal()
}
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)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
}
}
private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
copy(isVoiceRecording = action.isRecording)
}
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
setState {
// Makes sure currentComposerText is upToDate when accessing further setState
currentComposerText = action.text
this
}
updateIsSendButtonVisibility(true)
}
private fun subscribeToStateInternal() {
selectSubscribe(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage, TextComposerViewState::isVoiceRecording) { _, _, _ ->
updateIsSendButtonVisibility(false)
}
}
private fun updateIsSendButtonVisibility(triggerAnimation: Boolean) = setState {
val isSendButtonVisible = isComposerVisible && (sendMode !is SendMode.REGULAR || currentComposerText.isNotBlank())
if (this.isSendButtonVisible != isSendButtonVisible && triggerAnimation) {
_viewEvents.post(TextComposerViewEvents.AnimateSendButtonVisibility(isSendButtonVisible))
}
copy(isSendButtonVisible = isSendButtonVisible)
}
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,56 @@
/*
* 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 org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* Describes the current send mode:
* REGULAR: sends the text as a regular message
* QUOTE: User is currently quoting a message
* EDIT: User is currently editing an existing message
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
sealed class SendMode(open val text: String) {
data class REGULAR(
override val text: String,
val fromSharing: Boolean,
// This is necessary for forcing refresh on selectSubscribe
private val ts: Long = System.currentTimeMillis()
) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
data class TextComposerViewState(
val roomId: String,
val canSendMessage: Boolean = true,
val isVoiceRecording: Boolean = false,
val isSendButtonVisible : Boolean = false,
val sendMode: SendMode = SendMode.REGULAR("", false)
) : MvRxState {
val isComposerVisible: Boolean
get() = canSendMessage && !isVoiceRecording
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
}

View File

@ -172,7 +172,7 @@
android:contentDescription="@string/send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="gone"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="MissingPrefix"

View File

@ -184,7 +184,7 @@
android:contentDescription="@string/send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="gone"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"