Merge pull request #4125 from vector-im/feature/fga/text_composer_extract
Feature/fga/text composer extract
This commit is contained in:
commit
8644363005
|
@ -0,0 +1 @@
|
|||
Fix Reply/Edit mode animation is broken when sending
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue