From baf527ec9dec5dbe559d13bdb8399ff3c5546c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 29 Sep 2022 15:26:03 +0200 Subject: [PATCH 1/7] Extract MessageComposerFragment and VoiceRecorderFragment from TimelineFragment --- .../home/room/detail/TimelineFragment.kt | 793 +---------------- .../detail/composer/MessageComposerAction.kt | 2 +- .../composer/MessageComposerFragment.kt | 818 ++++++++++++++++++ .../detail/composer/MessageComposerView.kt | 8 - .../composer/voice/VoiceRecorderFragment.kt | 196 +++++ .../src/main/res/layout/fragment_composer.xml | 13 + .../src/main/res/layout/fragment_timeline.xml | 35 +- .../res/layout/fragment_voice_recorder.xml | 9 + 8 files changed, 1092 insertions(+), 782 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt create mode 100644 vector/src/main/res/layout/fragment_composer.xml create mode 100644 vector/src/main/res/layout/fragment_voice_recorder.xml diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 8a259b0eea..ae52d36c7e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -19,32 +19,23 @@ package im.vector.app.features.home.room.detail import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.content.res.Configuration import android.net.Uri import android.os.Build import android.os.Bundle -import android.text.Spannable -import android.text.format.DateUtils import android.text.method.LinkMovementMethod import android.view.HapticFeedbackConstants -import android.view.KeyEvent import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView -import android.widget.Toast -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri -import androidx.core.text.buildSpannedString import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat @@ -52,7 +43,6 @@ import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -63,11 +53,10 @@ import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.epoxy.addGlidePreloader import com.airbnb.epoxy.glidePreloader import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.args -import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.vanniktech.emoji.EmojiPopup import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.animations.play @@ -75,26 +64,23 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer -import im.vector.app.core.error.fatalError import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride import im.vector.app.core.extensions.ensureEndsLeftToRight import im.vector.app.core.extensions.filterDirectionOverrides import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequests -import im.vector.app.core.hardware.vibrate import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorMenuProvider -import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar -import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.time.Clock @@ -106,7 +92,6 @@ import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.KeyboardStateUtils -import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_MESSAGE import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.colorizeMatchingText @@ -116,7 +101,6 @@ import im.vector.app.core.utils.createUIHandler import im.vector.app.core.utils.isAnimationEnabled import im.vector.app.core.utils.isValidUrl import im.vector.app.core.utils.onPermissionDeniedDialog -import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.openLocation import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.core.utils.registerForPermissionsResult @@ -132,13 +116,7 @@ import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.MobileScreen -import im.vector.app.features.attachments.AttachmentTypeSelectorView -import im.vector.app.features.attachments.AttachmentsHelper -import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler -import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity -import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs -import im.vector.app.features.attachments.toGroupedContentAttachmentData import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.conference.ConferenceEvent @@ -146,22 +124,17 @@ import im.vector.app.features.call.conference.ConferenceEventEmitter import im.vector.app.features.call.conference.ConferenceEventObserver import im.vector.app.features.call.conference.JitsiCallViewModel import im.vector.app.features.call.webrtc.WebRtcCallManager -import im.vector.app.features.command.Command -import im.vector.app.features.command.ParsedCommand 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.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.CanSendStatus +import im.vector.app.features.home.room.detail.composer.MessageComposer import im.vector.app.features.home.room.detail.composer.MessageComposerAction -import im.vector.app.features.home.room.detail.composer.MessageComposerView -import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents +import im.vector.app.features.home.room.detail.composer.MessageComposerFragment import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel -import im.vector.app.features.home.room.detail.composer.MessageComposerViewState -import im.vector.app.features.home.room.detail.composer.SendMode import im.vector.app.features.home.room.detail.composer.boolean -import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView -import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState +import im.vector.app.features.home.room.detail.composer.voice.VoiceRecorderFragment import im.vector.app.features.home.room.detail.error.RoomNotFound import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.app.features.home.room.detail.timeline.TimelineEventController @@ -171,7 +144,6 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActi import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider -import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem @@ -188,7 +160,6 @@ import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet import im.vector.app.features.home.room.threads.ThreadsManager import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.html.EventHtmlRenderer -import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.invite.VectorInviteView import im.vector.app.features.location.LocationSharingMode @@ -206,25 +177,19 @@ import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity -import im.vector.app.features.share.SharedData import im.vector.app.features.spaces.share.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils -import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.billcarsonfr.jsonviewer.JSonViewerDialog -import org.commonmark.parser.Parser 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.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode @@ -233,11 +198,8 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent @@ -246,14 +208,11 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachme import org.matrix.android.sdk.api.session.room.send.SendState 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.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.toMatrixItem -import reactivecircus.flowbinding.android.view.focusChanges -import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber import java.net.URL import java.util.UUID @@ -264,8 +223,6 @@ class TimelineFragment : VectorBaseFragment(), TimelineEventController.Callback, VectorInviteView.Callback, - AttachmentTypeSelectorView.Callback, - AttachmentsHelper.Callback, GalleryOrCameraDialogHelper.Listener, CurrentCallsView.Callback, VectorMenuProvider { @@ -273,7 +230,6 @@ class TimelineFragment : @Inject lateinit var session: Session @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var timelineEventController: TimelineEventController - @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory @Inject lateinit var permalinkHandler: PermalinkHandler @Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @@ -292,45 +248,18 @@ class TimelineFragment : @Inject lateinit var shareIntentHandler: ShareIntentHandler @Inject lateinit var clock: Clock @Inject lateinit var vectorFeatures: VectorFeatures - @Inject lateinit var buildMeta: BuildMeta @Inject lateinit var galleryOrCameraDialogHelperFactory: GalleryOrCameraDialogHelperFactory companion object { - - /** - * Sanitize the display name. - * - * @param displayName the display name to sanitize - * @return the sanitized display name - */ - private fun sanitizeDisplayName(displayName: String): String { - if (displayName.endsWith(ircPattern)) { - return displayName.substring(0, displayName.length - ircPattern.length) - } - - return displayName - } - const val MAX_TYPING_MESSAGE_USERS_COUNT = 4 - private const val ircPattern = " (IRC)" } private lateinit var galleryOrCameraDialogHelper: GalleryOrCameraDialogHelper private val timelineArgs: TimelineArgs by args() - private val glideRequests by lazy { - GlideApp.with(this) - } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(timelineArgs.roomId) - } - private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) - } - - private val timelineViewModel: TimelineViewModel by fragmentViewModel() - private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel() + private val timelineViewModel: TimelineViewModel by activityViewModel() + private val messageComposerViewModel: MessageComposerViewModel by activityViewModel() private val debouncer = Debouncer(createUIHandler()) private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback @@ -351,21 +280,16 @@ class TimelineFragment : private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager private var modelBuildListener: OnModelBuildFinishedListener? = null - private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils private lateinit var callActionsHandler: StartCallActionsHandler - private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView - - private var lockSendButton = false private val currentCallsViewPresenter = CurrentCallsViewPresenter() private val isEmojiKeyboardVisible: Boolean get() = vectorPreferences.showEmojiKeyboard() private val lazyLoadedViews = RoomDetailLazyLoadedViews() - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { - createEmojiPopup() - } + + private lateinit var composer: MessageComposer override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -376,6 +300,24 @@ class TimelineFragment : timelineViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) } } + + val composer = childFragmentManager.findFragmentById(R.id.composerContainer) as? MessageComposerFragment ?: run { + val fragment = MessageComposerFragment() + fragment.arguments = timelineArgs.toMvRxBundle() + childFragmentManager.commitTransaction { + replace(R.id.composerContainer, fragment) + } + fragment + } + this.composer = composer + + childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) as? VoiceRecorderFragment ?: run { + childFragmentManager.commitTransaction { + val fragment = VoiceRecorderFragment() + fragment.arguments = timelineArgs.toMvRxBundle() + replace(R.id.voiceMessageRecorderContainer, fragment) + } + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -384,7 +326,6 @@ class TimelineFragment : sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) sharedActivityActionViewModel = activityViewModelProvider.get(RoomDetailSharedActionViewModel::class.java) knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) - attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() callActionsHandler = StartCallActionsHandler( roomId = timelineArgs.roomId, fragment = this, @@ -400,14 +341,11 @@ class TimelineFragment : setupToolbar(views.roomToolbar) .allowBack() setupRecyclerView() - setupComposer() setupNotificationView() setupJumpToReadMarkerView() setupActiveCallView() setupJumpToBottomView() - setupEmojiButton() setupRemoveJitsiWidgetView() - setupVoiceMessageView() setupLiveLocationIndicator() views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { @@ -432,19 +370,6 @@ class TimelineFragment : updateJumpToReadMarkerViewVisibility() } - messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> - if (!canSend.boolean()) { - return@onEach - } - when (mode) { - is SendMode.Regular -> renderRegularMode(mode.text) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) - is SendMode.Voice -> renderVoiceMessageMode(mode.text) - } - } - timelineViewModel.onEach( RoomDetailViewState::syncState, RoomDetailViewState::incrementalSyncRequestState, @@ -458,24 +383,6 @@ class TimelineFragment : ) } - messageComposerViewModel.observeViewEvents { - when (it) { - is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) - is MessageComposerViewEvents.SlashCommandConfirmationRequest -> handleSlashCommandConfirmationRequest(it) - is MessageComposerViewEvents.SendMessageResult -> renderSendMessageResult(it) - is MessageComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message) - is MessageComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) - is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it) - is MessageComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId) - is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> { - if (it.throwable is VoiceFailure.UnableToRecord) { - onCannotRecord() - } - showErrorInSnackbar(it.throwable) - } - } - } - timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> displayErrorMessage(it) @@ -515,51 +422,10 @@ class TimelineFragment : } if (savedInstanceState == null) { - handleShareData() handleSpaceShare() } } - private fun handleSlashCommandConfirmationRequest(action: MessageComposerViewEvents.SlashCommandConfirmationRequest) { - when (action.parsedCommand) { - is ParsedCommand.UnignoreUser -> promptUnignoreUser(action.parsedCommand) - else -> TODO("Add case for ${action.parsedCommand.javaClass.simpleName}") - } - lockSendButton = false - } - - private fun promptUnignoreUser(command: ParsedCommand.UnignoreUser) { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.room_participants_action_unignore_title) - .setMessage(getString(R.string.settings_unignore_user, command.userId)) - .setPositiveButton(R.string.unignore) { _, _ -> - messageComposerViewModel.handle(MessageComposerAction.SlashCommandConfirmed(command)) - } - .setNegativeButton(R.string.action_cancel, null) - .show() - } - - private fun renderVoiceMessageMode(content: String) { - ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> - views.voiceMessageRecorderView.isVisible = true - messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) - } - } - - private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.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(timelineViewModel) { @@ -580,11 +446,6 @@ class TimelineFragment : timelineViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) } - private fun onCannotRecord() { - // Update the UI, cancel the animation - messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(RecordingUiState.Idle)) - } - private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { val intent = VectorCallActivity.newIntent( context = vectorBaseActivity, @@ -601,12 +462,6 @@ class TimelineFragment : JoinReplacementRoomBottomSheet().show(childFragmentManager, tag) } - private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { - val tag = MigrateRoomBottomSheet::javaClass.name - MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) - .show(parentFragmentManager, tag) - } - private fun handleChatEffect(chatEffect: ChatEffect) { if (!requireContext().isAnimationEnabled()) { Timber.d("Do not perform chat effect, animations are disabled.") @@ -723,52 +578,6 @@ class TimelineFragment : ) } - private fun setupEmojiButton() { - views.composerLayout.views.composerEmojiButton.debouncedClicks { - emojiPopup.toggle() - } - } - - private fun createEmojiPopup(): EmojiPopup { - return EmojiPopup( - rootView = views.rootConstraintLayout, - keyboardAnimationStyle = R.style.emoji_fade_animation_style, - onEmojiPopupShownListener = { - views.composerLayout.views.composerEmojiButton.apply { - contentDescription = getString(R.string.a11y_close_emoji_picker) - setImageResource(R.drawable.ic_keyboard) - } - }, - onEmojiPopupDismissListener = lifecycleAwareDismissAction { - views.composerLayout.views.composerEmojiButton.apply { - contentDescription = getString(R.string.a11y_open_emoji_picker) - setImageResource(R.drawable.ic_insert_emoji) - } - }, - editText = views.composerLayout.views.composerEditText - ) - } - - /** - * Ensure dismiss actions only trigger when the fragment is in the started state. - * EmojiPopup by default dismisses onViewDetachedFromWindow, this can cause race conditions with onDestroyView. - */ - private fun lifecycleAwareDismissAction(action: () -> Unit): () -> Unit { - return { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - action() - } - } - } - - private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - // In this case, let the user start again the gesture - } else if (deniedPermanently) { - vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message) - } - } - private fun createFailedMessagesWarningCallback(): FailedMessagesWarningView.Callback { return object : FailedMessagesWarningView.Callback { override fun onDeleteAllClicked() { @@ -788,86 +597,6 @@ class TimelineFragment : } } - private fun setupVoiceMessageView() { - audioMessagePlaybackTracker.track(AudioMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView) - views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback { - - override fun onVoiceRecordingStarted() { - if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { - messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) - vibrate(requireContext()) - updateRecordingUiState(RecordingUiState.Recording(clock.epochMillis())) - } - } - - override fun onVoicePlaybackButtonClicked() { - messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseRecordingPlayback) - } - - override fun onVoiceRecordingCancelled() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) - vibrate(requireContext()) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onVoiceRecordingLocked() { - val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? RecordingUiState.Recording } - val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis() - updateRecordingUiState(RecordingUiState.Locked(startTime)) - } - - override fun onVoiceRecordingEnded() { - onSendVoiceMessage() - } - - override fun onSendVoiceMessage() { - messageComposerViewModel.handle( - MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId()) - ) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onDeleteVoiceMessage() { - messageComposerViewModel.handle( - MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId()) - ) - updateRecordingUiState(RecordingUiState.Idle) - } - - override fun onRecordingLimitReached() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(RecordingUiState.Draft) - } - - override fun onRecordingWaveformClicked() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(RecordingUiState.Draft) - } - - override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { - messageComposerViewModel.handle( - MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) - ) - } - - override fun onVoiceWaveformMoved(percentage: Float, duration: Int) { - messageComposerViewModel.handle( - MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) - ) - } - - private fun updateRecordingUiState(state: RecordingUiState) { - messageComposerViewModel.handle( - MessageComposerAction.OnVoiceRecordingUiStateChanged(state) - ) - } - } - } - private fun setupLiveLocationIndicator() { views.liveLocationStatusIndicator.stopButton.debouncedClicks { timelineViewModel.handle(RoomDetailAction.StopLiveLocationSharing) @@ -945,25 +674,6 @@ class TimelineFragment : .show() } - private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { - views.composerLayout.setTextIfDifferent("") - lockSendButton = false - navigator.openRoom(vectorBaseActivity, action.roomId) - } - - private fun handleShareData() { - when (val sharedData = timelineArgs.sharedData) { - is SharedData.Text -> { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) - } - is SharedData.Attachments -> { - // open share edition - onContentAttachmentsReady(sharedData.attachmentData) - } - null -> Timber.v("No share data to process") - } - } - private fun handleSpaceShare() { timelineArgs.openShareSpaceForId?.let { spaceId -> ShareSpaceBottomSheet.show(childFragmentManager, spaceId, true) @@ -974,13 +684,11 @@ class TimelineFragment : } override fun onDestroyView() { - messageComposerViewModel.endAllVoiceActions() lazyLoadedViews.unBind() timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) currentCallsViewPresenter.unBind() modelBuildListener = null - autoCompleter.clear() debouncer.cancelAll() views.timelineRecyclerView.cleanup() super.onDestroyView() @@ -1249,87 +957,11 @@ class TimelineFragment : .show() } - private fun renderRegularMode(content: String) { - autoCompleter.exitSpecialMode() - views.composerLayout.collapse() - views.composerLayout.setTextIfDifferent(content) - views.composerLayout.views.sendButton.contentDescription = getString(R.string.action_send) - } - - private fun renderSpecialMode( - event: TimelineEvent, - @DrawableRes iconRes: Int, - @StringRes descriptionRes: Int, - defaultContent: String - ) { - autoCompleter.enterSpecialMode() - // switch to expanded bar - views.composerLayout.views.composerRelatedMessageTitle.apply { - text = event.senderInfo.disambiguatedDisplayName - setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) - } - - val messageContent: MessageContent? = event.getLastMessageContent() - val nonFormattedBody = when (messageContent) { - is MessageAudioContent -> getAudioContentBodyText(messageContent) - is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() - is MessageBeaconInfoContent -> getString(R.string.live_location_description) - else -> messageContent?.body.orEmpty() - } - var formattedBody: CharSequence? = null - if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { - val parser = Parser.builder().build() - val document = parser.parse(messageContent.formattedBody ?: messageContent.body) - formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) - } - views.composerLayout.views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) - - // Image Event - val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) - val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerLayout.views.composerRelatedMessageImage) - true - } else { - imageContentRenderer.clear(views.composerLayout.views.composerRelatedMessageImage) - false - } - - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible - - views.composerLayout.setTextIfDifferent(defaultContent) - - views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes) - - avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerLayout.views.composerRelatedMessageAvatar) - - views.composerLayout.expand { - if (isAdded) { - // need to do it here also when not using quick reply - focusComposerAndShowKeyboard() - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible - } - } - focusComposerAndShowKeyboard() - } - - private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { - val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) - return if (messageContent.voiceMessageIndicator != null) { - getString(R.string.voice_message_reply_content, formattedDuration) - } else { - getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) - } - } - override fun onResume() { super.onResume() notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null - - // Removed listeners should be set again - setupVoiceMessageView() } private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { @@ -1347,52 +979,6 @@ class TimelineFragment : override fun onPause() { super.onPause() notificationDrawerManager.setCurrentRoom(null) - audioMessagePlaybackTracker.pauseAllPlaybacks() - - if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { - // we're rotating, maintain any active recordings - } else { - messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) - } - } - - private val attachmentFileActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onFileResult(it.data) - } - } - - private val attachmentContactActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onContactResult(it.data) - } - } - - private val attachmentMediaActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onMediaResult(it.data) - } - } - - private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onCameraResult() - } - } - - private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { - if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onCameraVideoResult() - } - } - - private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> - val data = activityResult.data ?: return@registerStartForActivityResult - if (activityResult.resultCode == Activity.RESULT_OK) { - val sendData = AttachmentsPreviewActivity.getOutput(data) - val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) - timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) - } } private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -1465,7 +1051,7 @@ class TimelineFragment : override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { (model as? AbsMessageItem)?.attributes?.informationData?.let { val eventId = it.eventId - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId, composer.getCurrentText().toString())) } } @@ -1532,124 +1118,6 @@ class TimelineFragment : } } - private fun setupComposer() { - val composerEditText = views.composerLayout.views.composerEditText - autoCompleter.setup(composerEditText) - - observerUserTyping() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - composerEditText.setUseIncognitoKeyboard(vectorPreferences.useIncognitoKeyboard()) - } - composerEditText.setSendMessageWithEnter(vectorPreferences.sendMessageWithEnter()) - - composerEditText.setOnEditorActionListener { v, actionId, keyEvent -> - val imeActionId = actionId and EditorInfo.IME_MASK_ACTION - if (EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId) { - sendTextMessage(v.text) - true - } - // Add external keyboard functionality (to send messages) - else if (null != keyEvent && - !keyEvent.isShiftPressed && - keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && - resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) { - sendTextMessage(v.text) - true - } else false - } - - views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() - - if (isThreadTimeLine() && timelineArgs.threadTimelineArgs?.showKeyboard == true) { - // Show keyboard when the user started a thread - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) - } - views.composerLayout.callback = object : MessageComposerView.Callback { - override fun onAddAttachment() { - if (!::attachmentTypeSelector.isInitialized) { - attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@TimelineFragment) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.LOCATION, - vectorFeatures.isLocationSharingEnabled(), - ) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() - ) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.VOICE_BROADCAST, - vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission - ) - } - attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) - } - - override fun onExpandOrCompactChange() { - views.composerLayout.views.composerEmojiButton.isVisible = isEmojiKeyboardVisible - } - - override fun onSendMessage(text: CharSequence) { - sendTextMessage(text) - } - - override fun onCloseRelatedMessage() { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false)) - } - - override fun onRichContentSelected(contentUri: Uri): Boolean { - return sendUri(contentUri) - } - - override fun onTextChanged(text: CharSequence) { - messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) - } - } - } - - private fun sendTextMessage(text: CharSequence) { - if (lockSendButton) { - Timber.w("Send button is locked") - return - } - if (text.isNotBlank()) { - // We collapse ASAP, if not there will be a slight annoying delay - views.composerLayout.collapse(true) - lockSendButton = true - messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) - emojiPopup.dismiss() - } - } - - private fun observerUserTyping() { - if (isThreadTimeLine()) return - views.composerLayout.views.composerEditText.textChanges() - .skipInitialValue() - .debounce(300) - .map { it.isNotEmpty() } - .onEach { - Timber.d("Typing: User is typing: $it") - messageComposerViewModel.handle(MessageComposerAction.UserIsTyping(it)) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - - views.composerLayout.views.composerEditText.focusChanges() - .onEach { - timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - } - - private fun sendUri(uri: Uri): Boolean { - val shareIntent = Intent(Intent.ACTION_SEND, uri) - val isHandled = shareIntentHandler.handleIncomingShareIntent(shareIntent, ::onContentAttachmentsReady, onPlainText = { - fatalError("Should not happen as we're generating a File based share Intent", vectorPreferences.failFast()) - }) - if (!isHandled) { - Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() - } - return isHandled - } - override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> invalidateOptionsMenu() if (mainState.asyncRoomSummary is Fail) { @@ -1673,12 +1141,6 @@ class TimelineFragment : lazyLoadedViews.inviteView(false)?.isVisible = false if (mainState.tombstoneEvent == null) { - views.composerLayout.isInvisible = !messageComposerState.isComposerVisible - views.voiceMessageRecorderView.isVisible = messageComposerState.isVoiceMessageRecorderVisible - views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible - views.voiceMessageRecorderView.render(messageComposerState.voiceRecordingUiState) - views.composerLayout.setRoomEncrypted(summary.isEncrypted) - // views.composerLayout.alwaysShowSendButton = false when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -1733,8 +1195,7 @@ class TimelineFragment : } private fun FragmentTimelineBinding.hideComposerViews() { - composerLayout.isVisible = false - voiceMessageRecorderView.isVisible = false + composerContainer.isVisible = false } private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) { @@ -1787,57 +1248,6 @@ class TimelineFragment : } } - private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { - when (sendMessageResult) { - is MessageComposerViewEvents.SlashCommandLoading -> { - showLoading(null) - } - is MessageComposerViewEvents.SlashCommandError -> { - displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) - } - is MessageComposerViewEvents.SlashCommandUnknown -> { - displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) - } - is MessageComposerViewEvents.SlashCommandResultOk -> { - handleSlashCommandResultOk(sendMessageResult.parsedCommand) - } - is MessageComposerViewEvents.SlashCommandResultError -> { - dismissLoadingDialog() - displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) - } - is MessageComposerViewEvents.SlashCommandNotImplemented -> { - displayCommandError(getString(R.string.not_implemented)) - } - is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { - displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) - } - } - - lockSendButton = false - } - - private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { - dismissLoadingDialog() - views.composerLayout.setTextIfDifferent("") - when (parsedCommand) { - is ParsedCommand.DevTools -> { - navigator.openDevTools(requireContext(), timelineArgs.roomId) - } - is ParsedCommand.SetMarkdown -> { - showSnackWithMessage(getString(if (parsedCommand.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) - } - else -> Unit - } - } - - private fun displayCommandError(message: String) { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.command_error) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .show() - } - private fun displayE2eError(withHeldCode: WithHeldCode?) { val msgId = when (withHeldCode) { WithHeldCode.BLACKLISTED -> R.string.crypto_error_withheld_blacklisted @@ -2066,7 +1476,7 @@ class TimelineFragment : inMemory = inMemory ) { pairs -> pairs.add(Pair(views.roomToolbar, ViewCompat.getTransitionName(views.roomToolbar) ?: "")) - pairs.add(Pair(views.composerLayout, ViewCompat.getTransitionName(views.composerLayout) ?: "")) + pairs.add(Pair(views.composerContainer, ViewCompat.getTransitionName(views.composerContainer) ?: "")) } } @@ -2078,16 +1488,10 @@ class TimelineFragment : view = view ) { pairs -> pairs.add(Pair(views.roomToolbar, ViewCompat.getTransitionName(views.roomToolbar) ?: "")) - pairs.add(Pair(views.composerLayout, ViewCompat.getTransitionName(views.composerLayout) ?: "")) + pairs.add(Pair(views.composerContainer, ViewCompat.getTransitionName(views.composerContainer) ?: "")) } } - private fun cleanUpAfterPermissionNotGranted() { - // Reset all pending data - timelineViewModel.pendingAction = null - attachmentsHelper.pendingType = null - } - override fun onLoadMore(direction: Timeline.Direction) { timelineViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) } @@ -2284,6 +1688,11 @@ class TimelineFragment : } } + private fun cleanUpAfterPermissionNotGranted() { + // Reset all pending data + timelineViewModel.pendingAction = null + } + private fun onSaveActionClicked(action: EventSharedAction.Save) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !checkPermissions(PERMISSIONS_FOR_WRITING_FILES, requireActivity(), saveActionActivityResultLauncher)) { @@ -2361,17 +1770,17 @@ class TimelineFragment : if (action.eventType in EventType.POLL_START) { navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT) } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { - messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId)) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } is EventSharedAction.Quote -> { - messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId, composer.getCurrentText().toString())) } is EventSharedAction.Reply -> { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId, views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId, composer.getCurrentText().toString())) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } @@ -2479,54 +1888,7 @@ class TimelineFragment : */ @SuppressLint("SetTextI18n") private fun insertUserDisplayNameInTextEditor(userId: String) { - val startToCompose = views.composerLayout.text.isNullOrBlank() - - if (startToCompose && - userId == session.myUserId) { - // Empty composer, current user: start an emote - views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") - views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) - } else { - val roomMember = timelineViewModel.getMember(userId) - // TODO move logic outside of fragment - (roomMember?.displayName ?: userId) - .let { sanitizeDisplayName(it) } - .let { displayName -> - buildSpannedString { - append(displayName) - setSpan( - PillImageSpan( - glideRequests, - avatarRenderer, - requireContext(), - MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) - ) - .also { it.bind(views.composerLayout.views.composerEditText) }, - 0, - displayName.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - append(if (startToCompose) ": " else " ") - }.let { pill -> - if (startToCompose) { - if (displayName.startsWith("/")) { - // Ensure displayName will not be interpreted as a Slash command - views.composerLayout.views.composerEditText.append("\\") - } - views.composerLayout.views.composerEditText.append(pill) - } else { - views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) - } - } - } - } - focusComposerAndShowKeyboard() - } - - private fun focusComposerAndShowKeyboard() { - if (views.composerLayout.isVisible) { - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) - } + composer.insertUserDisplayNameInTextEditor(userId) } private fun showSnackWithMessage(message: String) { @@ -2630,79 +1992,6 @@ class TimelineFragment : } } - // AttachmentTypeSelectorView.Callback - private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - val pendingType = attachmentsHelper.pendingType - if (pendingType != null) { - attachmentsHelper.pendingType = null - launchAttachmentProcess(pendingType) - } - } else { - if (deniedPermanently) { - activity?.onPermissionDeniedDialog(R.string.denied_permission_generic) - } - cleanUpAfterPermissionNotGranted() - } - } - - override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { - if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { - launchAttachmentProcess(type) - } else { - attachmentsHelper.pendingType = type - } - } - - private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { - when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( - activity = requireActivity(), - vectorPreferences = vectorPreferences, - cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, - cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher - ) - AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) - AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) - AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) - AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) - AttachmentTypeSelectorView.Type.LOCATION -> { - navigator - .openLocationSharing( - context = requireContext(), - roomId = timelineArgs.roomId, - mode = LocationSharingMode.STATIC_SHARING, - initialLocationData = null, - locationOwnerId = session.myUserId - ) - } - AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(RoomDetailAction.StartVoiceBroadcast) - } - } - - // AttachmentsHelper.Callback - override fun onContentAttachmentsReady(attachments: List) { - val grouped = attachments.toGroupedContentAttachmentData() - if (grouped.notPreviewables.isNotEmpty()) { - // Send the not previewable attachments right now (?) - timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) - } - if (grouped.previewables.isNotEmpty()) { - val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) - contentAttachmentActivityResultLauncher.launch(intent) - } - } - - override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { - val formattedContact = contactAttachment.toHumanReadable() - messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false)) - } - - override fun onAttachmentError(throwable: Throwable) { - showFailure(throwable) - } - private fun onViewWidgetsClicked() { RoomWidgetsBottomSheet.newInstance() .show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET") diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 527f42a67a..ac39e0e915 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -24,7 +24,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent sealed class MessageComposerAction : VectorViewModelAction { data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() - data class EnterEditMode(val eventId: String, val text: String) : MessageComposerAction() + data class EnterEditMode(val eventId: String) : MessageComposerAction() data class EnterQuoteMode(val eventId: String, val text: String) : MessageComposerAction() data class EnterReplyMode(val eventId: String, val text: String) : MessageComposerAction() data class EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt new file mode 100644 index 0000000000..103950b3b8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -0,0 +1,818 @@ +/* + * Copyright (c) 2022 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 android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.Spannable +import android.text.format.DateUtils +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.text.buildSpannedString +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.args +import com.airbnb.mvrx.existingViewModel +import com.airbnb.mvrx.withState +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.vanniktech.emoji.EmojiPopup +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.error.fatalError +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.glide.GlideApp +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.lifecycleAwareLazy +import im.vector.app.core.platform.showOptimizedSnackbar +import im.vector.app.core.resources.BuildMeta +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.app.databinding.FragmentComposerBinding +import im.vector.app.features.VectorFeatures +import im.vector.app.features.attachments.AttachmentTypeSelectorView +import im.vector.app.features.attachments.AttachmentsHelper +import im.vector.app.features.attachments.ContactAttachment +import im.vector.app.features.attachments.ShareIntentHandler +import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity +import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs +import im.vector.app.features.attachments.toGroupedContentAttachmentData +import im.vector.app.features.command.Command +import im.vector.app.features.command.ParsedCommand +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.AutoCompleter +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.TimelineViewModel +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView +import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData +import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet +import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.html.PillImageSpan +import im.vector.app.features.html.PillsPostProcessor +import im.vector.app.features.location.LocationSharingMode +import im.vector.app.features.media.ImageContentRenderer +import im.vector.app.features.poll.PollMode +import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.share.SharedData +import im.vector.app.features.voice.VoiceFailure +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +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.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import reactivecircus.flowbinding.android.view.focusChanges +import reactivecircus.flowbinding.android.widget.textChanges +import timber.log.Timber +import javax.inject.Inject + +interface MessageComposer { + fun getCurrentText(): Editable? + fun insertUserDisplayNameInTextEditor(userId: String) +} + +@AndroidEntryPoint +class MessageComposerFragment : VectorBaseFragment(), MessageComposer, AttachmentsHelper.Callback, AttachmentTypeSelectorView.Callback { + + companion object { + private const val ircPattern = " (IRC)" + } + + @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider + @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer + @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var imageContentRenderer: ImageContentRenderer + @Inject lateinit var shareIntentHandler: ShareIntentHandler + @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory + @Inject lateinit var vectorPreferences: VectorPreferences + @Inject lateinit var vectorFeatures: VectorFeatures + @Inject lateinit var buildMeta: BuildMeta + @Inject lateinit var session: Session + + private val timelineArgs: TimelineArgs by args() + + private val autoCompleter: AutoCompleter by lazy { + autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) + } + + private val pillsPostProcessor by lazy { + pillsPostProcessorFactory.create(timelineArgs.roomId) + } + + private val emojiPopup: EmojiPopup by lifecycleAwareLazy { + createEmojiPopup() + } + + private val glideRequests by lazy { + GlideApp.with(this) + } + + private val isEmojiKeyboardVisible: Boolean + get() = vectorPreferences.showEmojiKeyboard() + + private var lockSendButton = false + + private lateinit var attachmentsHelper: AttachmentsHelper + private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView + + private val timelineViewModel: TimelineViewModel by existingViewModel() + private val messageComposerViewModel: MessageComposerViewModel by existingViewModel() + private lateinit var sharedActionViewModel: MessageSharedActionViewModel + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentComposerBinding { + return FragmentComposerBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) + + attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + + setupComposer() + setupEmojiButton() + + messageComposerViewModel.observeViewEvents { + when (it) { + is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) + is MessageComposerViewEvents.SlashCommandConfirmationRequest -> handleSlashCommandConfirmationRequest(it) + is MessageComposerViewEvents.SendMessageResult -> renderSendMessageResult(it) + is MessageComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message) + is MessageComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it) + is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it) + is MessageComposerViewEvents.OpenRoomMemberProfile -> openRoomMemberProfile(it.userId) + is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> { + if (it.throwable is VoiceFailure.UnableToRecord) { + onCannotRecord() + } + showErrorInSnackbar(it.throwable) + } + } + } + + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> + if (!canSend.boolean()) { + return@onEach + } + when (mode) { + is SendMode.Regular -> renderRegularMode(mode.text) + is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) + is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text) + is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) + is SendMode.Voice -> renderVoiceMessageMode(mode.text) + } + } + + if (savedInstanceState != null) { + handleShareData() + } + } + + override fun onPause() { + super.onPause() + + if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { + // we're rotating, maintain any active recordings + } else { + messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) + } + } + + override fun onDestroyView() { + super.onDestroyView() + + autoCompleter.clear() + messageComposerViewModel.endAllVoiceActions() + } + + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + if (mainState.tombstoneEvent != null) return@withState + + views.root.isInvisible = !messageComposerState.isComposerVisible + views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible + } + + private fun setupComposer() { + val composerEditText = views.composerLayout.views.composerEditText + composerEditText.setHint(R.string.room_message_placeholder) + + autoCompleter.setup(composerEditText) + + observerUserTyping() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + composerEditText.setUseIncognitoKeyboard(vectorPreferences.useIncognitoKeyboard()) + } + composerEditText.setSendMessageWithEnter(vectorPreferences.sendMessageWithEnter()) + + composerEditText.setOnEditorActionListener { v, actionId, keyEvent -> + val imeActionId = actionId and EditorInfo.IME_MASK_ACTION + if (EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId) { + sendTextMessage(v.text) + true + } + // Add external keyboard functionality (to send messages) + else if (null != keyEvent && + !keyEvent.isShiftPressed && + keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && + resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) { + sendTextMessage(v.text) + true + } else false + } + + views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() + + if (isThreadTimeLine() && timelineArgs.threadTimelineArgs?.showKeyboard == true) { + // Show keyboard when the user started a thread + views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) + } + views.composerLayout.callback = object : MessageComposerView.Callback { + override fun onAddAttachment() { + if (!::attachmentTypeSelector.isInitialized) { + attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.LOCATION, + vectorFeatures.isLocationSharingEnabled(), + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.VOICE_BROADCAST, + vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission + ) + } + attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) + } + + override fun onExpandOrCompactChange() { + views.composerLayout.views.composerEmojiButton.isVisible = isEmojiKeyboardVisible + } + + override fun onSendMessage(text: CharSequence) { + sendTextMessage(text) + } + + override fun onCloseRelatedMessage() { + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false)) + } + + override fun onRichContentSelected(contentUri: Uri): Boolean { + return sendUri(contentUri) + } + + override fun onTextChanged(text: CharSequence) { + messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) + } + } + } + + private fun sendTextMessage(text: CharSequence) { + if (lockSendButton) { + Timber.w("Send button is locked") + return + } + if (text.isNotBlank()) { + // We collapse ASAP, if not there will be a slight annoying delay + views.composerLayout.collapse(true) + lockSendButton = true + messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) + emojiPopup.dismiss() + } + } + + private fun sendUri(uri: Uri): Boolean { + val shareIntent = Intent(Intent.ACTION_SEND, uri) + val isHandled = shareIntentHandler.handleIncomingShareIntent(shareIntent, ::onContentAttachmentsReady, onPlainText = { + fatalError("Should not happen as we're generating a File based share Intent", vectorPreferences.failFast()) + }) + if (!isHandled) { + Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() + } + return isHandled + } + + private fun renderRegularMode(content: String) { + autoCompleter.exitSpecialMode() + views.composerLayout.collapse() + views.composerLayout.setTextIfDifferent(content) + views.composerLayout.views.sendButton.contentDescription = getString(R.string.action_send) + } + + private fun renderSpecialMode( + event: TimelineEvent, + @DrawableRes iconRes: Int, + @StringRes descriptionRes: Int, + defaultContent: String + ) { + autoCompleter.enterSpecialMode() + // switch to expanded bar + views.composerLayout.views.composerRelatedMessageTitle.apply { + text = event.senderInfo.disambiguatedDisplayName + setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) + } + + val messageContent: MessageContent? = event.getLastMessageContent() + val nonFormattedBody = when (messageContent) { + is MessageAudioContent -> getAudioContentBodyText(messageContent) + is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> getString(R.string.live_location_description) + else -> messageContent?.body.orEmpty() + } + var formattedBody: CharSequence? = null + if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { + val parser = Parser.builder().build() + val document = parser.parse(messageContent.formattedBody ?: messageContent.body) + formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) + } + views.composerLayout.views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) + val isImageVisible = if (data != null) { + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerLayout.views.composerRelatedMessageImage) + true + } else { + imageContentRenderer.clear(views.composerLayout.views.composerRelatedMessageImage) + false + } + + views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible + + views.composerLayout.setTextIfDifferent(defaultContent) + + views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) + views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes) + + avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerLayout.views.composerRelatedMessageAvatar) + + views.composerLayout.expand { + if (isAdded) { + // need to do it here also when not using quick reply + focusComposerAndShowKeyboard() + views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible + } + } + focusComposerAndShowKeyboard() + } + + private fun observerUserTyping() { + if (isThreadTimeLine()) return + views.composerLayout.views.composerEditText.textChanges() + .skipInitialValue() + .debounce(300) + .map { it.isNotEmpty() } + .onEach { + Timber.d("Typing: User is typing: $it") + messageComposerViewModel.handle(MessageComposerAction.UserIsTyping(it)) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + + views.composerLayout.views.composerEditText.focusChanges() + .onEach { + timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun focusComposerAndShowKeyboard() { + if (views.composerLayout.isVisible) { + views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) + } + } + + private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { + if (event.isVisible) { + views.root.views.sendButton.alpha = 0f + views.root.views.sendButton.isVisible = true + views.root.views.sendButton.animate().alpha(1f).setDuration(150).start() + } else { + views.root.views.sendButton.isInvisible = true + } + } + + private fun renderVoiceMessageMode(content: String) { + ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> + // TODO: review this behaviour +// views.voiceMessageRecorderView.isVisible = true + messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) + } + } + + private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { + val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) + return if (messageContent.voiceMessageIndicator != null) { + getString(R.string.voice_message_reply_content, formattedDuration) + } else { + getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) + } + } + + private fun createEmojiPopup(): EmojiPopup { + return EmojiPopup( + rootView = views.root, + keyboardAnimationStyle = R.style.emoji_fade_animation_style, + onEmojiPopupShownListener = { + views.composerLayout.views.composerEmojiButton.apply { + contentDescription = getString(R.string.a11y_close_emoji_picker) + setImageResource(R.drawable.ic_keyboard) + } + }, + onEmojiPopupDismissListener = lifecycleAwareDismissAction { + views.composerLayout.views.composerEmojiButton.apply { + contentDescription = getString(R.string.a11y_open_emoji_picker) + setImageResource(R.drawable.ic_insert_emoji) + } + }, + editText = views.composerLayout.views.composerEditText + ) + } + + /** + * Ensure dismiss actions only trigger when the fragment is in the started state. + * EmojiPopup by default dismisses onViewDetachedFromWindow, this can cause race conditions with onDestroyView. + */ + private fun lifecycleAwareDismissAction(action: () -> Unit): () -> Unit { + return { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + action() + } + } + } + + private fun setupEmojiButton() { + views.composerLayout.views.composerEmojiButton.debouncedClicks { + emojiPopup.toggle() + } + } + + private fun onCannotRecord() { + // Update the UI, cancel the animation + messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(VoiceMessageRecorderView.RecordingUiState.Idle)) + } + + private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { + views.composerLayout.setTextIfDifferent("") + lockSendButton = false + navigator.openRoom(vectorBaseActivity, action.roomId) + } + + private fun handleSlashCommandConfirmationRequest(action: MessageComposerViewEvents.SlashCommandConfirmationRequest) { + when (action.parsedCommand) { + is ParsedCommand.UnignoreUser -> promptUnignoreUser(action.parsedCommand) + else -> TODO("Add case for ${action.parsedCommand.javaClass.simpleName}") + } + lockSendButton = false + } + + private fun promptUnignoreUser(command: ParsedCommand.UnignoreUser) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.room_participants_action_unignore_title) + .setMessage(getString(R.string.settings_unignore_user, command.userId)) + .setPositiveButton(R.string.unignore) { _, _ -> + messageComposerViewModel.handle(MessageComposerAction.SlashCommandConfirmed(command)) + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { + when (sendMessageResult) { + is MessageComposerViewEvents.SlashCommandLoading -> { + showLoading(null) + } + is MessageComposerViewEvents.SlashCommandError -> { + displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) + } + is MessageComposerViewEvents.SlashCommandUnknown -> { + displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) + } + is MessageComposerViewEvents.SlashCommandResultOk -> { + handleSlashCommandResultOk(sendMessageResult.parsedCommand) + } + is MessageComposerViewEvents.SlashCommandResultError -> { + dismissLoadingDialog() + displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) + } + is MessageComposerViewEvents.SlashCommandNotImplemented -> { + displayCommandError(getString(R.string.not_implemented)) + } + is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { + displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) + } + } + + lockSendButton = false + } + + private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { + dismissLoadingDialog() + views.composerLayout.setTextIfDifferent("") + when (parsedCommand) { + is ParsedCommand.DevTools -> { + navigator.openDevTools(requireContext(), timelineArgs.roomId) + } + is ParsedCommand.SetMarkdown -> { + showSnackWithMessage(getString(if (parsedCommand.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) + } + else -> Unit + } + } + + private fun displayCommandError(message: String) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.command_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun showSnackWithMessage(message: String) { + view?.showOptimizedSnackbar(message) + } + + private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { + val tag = MigrateRoomBottomSheet::javaClass.name + MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) + .show(parentFragmentManager, tag) + } + + private fun openRoomMemberProfile(userId: String) { + navigator.openRoomMemberProfile(userId = userId, roomId = timelineArgs.roomId, context = requireActivity()) + } + + private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> + val data = activityResult.data ?: return@registerStartForActivityResult + if (activityResult.resultCode == Activity.RESULT_OK) { + val sendData = AttachmentsPreviewActivity.getOutput(data) + val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) + timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) + } + } + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null. + */ + fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + + /** + * Returns true if the current room is a Thread room, false otherwise. + */ + private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + + + // AttachmentsHelper.Callback + override fun onContentAttachmentsReady(attachments: List) { + val grouped = attachments.toGroupedContentAttachmentData() + if (grouped.notPreviewables.isNotEmpty()) { + // Send the not previewable attachments right now (?) + timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) + } + if (grouped.previewables.isNotEmpty()) { + val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) + contentAttachmentActivityResultLauncher.launch(intent) + } + } + + override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { + val formattedContact = contactAttachment.toHumanReadable() + messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false)) + } + + override fun onAttachmentError(throwable: Throwable) { + showFailure(throwable) + } + + // AttachmentTypeSelectorView.Callback + private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + val pendingType = attachmentsHelper.pendingType + if (pendingType != null) { + attachmentsHelper.pendingType = null + launchAttachmentProcess(pendingType) + } + } else { + if (deniedPermanently) { + activity?.onPermissionDeniedDialog(R.string.denied_permission_generic) + } + cleanUpAfterPermissionNotGranted() + } + } + + private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { + when (type) { + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + activity = requireActivity(), + vectorPreferences = vectorPreferences, + cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, + cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher + ) + AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) + AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) + AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) + AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) + AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) + AttachmentTypeSelectorView.Type.LOCATION -> { + navigator + .openLocationSharing( + context = requireContext(), + roomId = timelineArgs.roomId, + mode = LocationSharingMode.STATIC_SHARING, + initialLocationData = null, + locationOwnerId = session.myUserId + ) + } + AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(RoomDetailAction.StartVoiceBroadcast) + } + } + + override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { + if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { + launchAttachmentProcess(type) + } else { + attachmentsHelper.pendingType = type + } + } + + private val attachmentFileActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onFileResult(it.data) + } + } + + private val attachmentContactActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onContactResult(it.data) + } + } + + private val attachmentMediaActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onMediaResult(it.data) + } + } + + private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraResult() + } + } + + private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraVideoResult() + } + } + + private fun cleanUpAfterPermissionNotGranted() { + // Reset all pending data + timelineViewModel.pendingAction = null + attachmentsHelper.pendingType = null + } + + private fun handleShareData() { + when (val sharedData = timelineArgs.sharedData) { + is SharedData.Text -> { + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) + } + is SharedData.Attachments -> { + // open share edition + onContentAttachmentsReady(sharedData.attachmentData) + } + null -> Timber.v("No share data to process") + } + } + + override fun getCurrentText(): Editable? { + return views.composerLayout.text + } + + override fun insertUserDisplayNameInTextEditor(userId: String) { + val startToCompose = views.composerLayout.text.isNullOrBlank() + + if (startToCompose && + userId == session.myUserId) { + // Empty composer, current user: start an emote + views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") + views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) + } else { + val roomMember = timelineViewModel.getMember(userId) + // TODO move logic outside of fragment + sanitizeDisplayName(roomMember?.displayName ?: userId) + .let { displayName -> + buildSpannedString { + append(displayName) + setSpan( + PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) + ) + .also { it.bind(views.composerLayout.views.composerEditText) }, + 0, + displayName.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(if (startToCompose) ": " else " ") + }.let { pill -> + if (startToCompose) { + if (displayName.startsWith("/")) { + // Ensure displayName will not be interpreted as a Slash command + views.composerLayout.views.composerEditText.append("\\") + } + views.composerLayout.views.composerEditText.append(pill) + } else { + views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) + } + } + } + } + focusComposerAndShowKeyboard() + } + + /** + * Sanitize the display name. + * + * @param displayName the display name to sanitize + * @return the sanitized display name + */ + private fun sanitizeDisplayName(displayName: String): String { + if (displayName.endsWith(ircPattern)) { + return displayName.substring(0, displayName.length - ircPattern.length) + } + + return displayName + } + + /** Set whether the keyboard should disable personalized learning. */ + @RequiresApi(Build.VERSION_CODES.O) + private fun EditText.setUseIncognitoKeyboard(useIncognitoKeyboard: Boolean) { + imeOptions = if (useIncognitoKeyboard) { + imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + } else { + imeOptions and EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING.inv() + } + } + + /** Set whether enter should send the message or add a new line. */ + private fun EditText.setSendMessageWithEnter(sendMessageWithEnter: Boolean) { + if (sendMessageWithEnter) { + inputType = inputType and EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE.inv() + imeOptions = imeOptions or EditorInfo.IME_ACTION_SEND + } else { + inputType = inputType or EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE + imeOptions = imeOptions and EditorInfo.IME_ACTION_SEND.inv() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index b1b2c87e9c..1935c9460b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -149,12 +149,4 @@ class MessageComposerView @JvmOverloads constructor( } TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) } - - fun setRoomEncrypted(isEncrypted: Boolean) { - if (isEncrypted) { - views.composerEditText.setHint(R.string.room_message_placeholder) - } else { - views.composerEditText.setHint(R.string.room_message_placeholder) - } - } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt new file mode 100644 index 0000000000..964331f924 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2022 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.voice + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import com.airbnb.mvrx.existingViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.hardware.vibrate +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.time.Clock +import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_MESSAGE +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedSnackbar +import im.vector.app.core.utils.registerForPermissionsResult +import im.vector.app.databinding.FragmentVoiceRecorderBinding +import im.vector.app.features.home.room.detail.TimelineViewModel +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.detail.composer.MessageComposerAction +import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents +import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import javax.inject.Inject + +@AndroidEntryPoint +class VoiceRecorderFragment : VectorBaseFragment() { + + @Inject lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker + @Inject lateinit var clock: Clock + + private val timelineArgs: TimelineArgs by args() + + private val timelineViewModel: TimelineViewModel by existingViewModel() + private val messageComposerViewModel: MessageComposerViewModel by existingViewModel() + + private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + // In this case, let the user start again the gesture + } else if (deniedPermanently) { + vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message) + } + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentVoiceRecorderBinding { + return FragmentVoiceRecorderBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + messageComposerViewModel.observeViewEvents { + when (it) { + is MessageComposerViewEvents.AnimateSendButtonVisibility -> handleSendButtonVisibilityChanged(it.isVisible) + else -> Unit + } + } + } + + override fun onResume() { + super.onResume() + + // Removed listeners should be set again + setupVoiceMessageView() + } + + override fun onPause() { + super.onPause() + + audioMessagePlaybackTracker.pauseAllPlaybacks() + } + + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + if (mainState.tombstoneEvent != null) return@withState + + with(views.root) { + isVisible = messageComposerState.isVoiceMessageRecorderVisible + render(messageComposerState.voiceRecordingUiState) + } + } + + private fun handleSendButtonVisibilityChanged(isSendButtonVisible: Boolean) { + if (isSendButtonVisible) { + views.root.isVisible = false + } else { + views.root.alpha = 0f + views.root.isVisible = true + views.root.animate().alpha(1f).setDuration(150).start() + } + } + + private fun setupVoiceMessageView() { + audioMessagePlaybackTracker.track(AudioMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView) + views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback { + + override fun onVoiceRecordingStarted() { + if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { + messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) + vibrate(requireContext()) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Recording(clock.epochMillis())) + } + } + + override fun onVoicePlaybackButtonClicked() { + messageComposerViewModel.handle(MessageComposerAction.PlayOrPauseRecordingPlayback) + } + + override fun onVoiceRecordingCancelled() { + messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) + vibrate(requireContext()) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onVoiceRecordingLocked() { + val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? VoiceMessageRecorderView.RecordingUiState.Recording } + val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis() + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Locked(startTime)) + } + + override fun onVoiceRecordingEnded() { + onSendVoiceMessage() + } + + override fun onSendVoiceMessage() { + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId()) + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onDeleteVoiceMessage() { + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId()) + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) + } + + override fun onRecordingLimitReached() { + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) + } + + override fun onRecordingWaveformClicked() { + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) + } + + override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + + override fun onVoiceWaveformMoved(percentage: Float, duration: Int) { + messageComposerViewModel.handle( + MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage) + ) + } + + private fun updateRecordingUiState(state: VoiceMessageRecorderView.RecordingUiState) { + messageComposerViewModel.handle( + MessageComposerAction.OnVoiceRecordingUiStateChanged(state) + ) + } + } + } + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null. + */ + fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + +} diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml new file mode 100644 index 0000000000..0f79500da9 --- /dev/null +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -0,0 +1,13 @@ + + diff --git a/vector/src/main/res/layout/fragment_timeline.xml b/vector/src/main/res/layout/fragment_timeline.xml index f8a31d3281..2078d729db 100644 --- a/vector/src/main/res/layout/fragment_timeline.xml +++ b/vector/src/main/res/layout/fragment_timeline.xml @@ -119,33 +119,26 @@ android:layout_height="wrap_content" android:inflatedId="@+id/failedMessagesWarningStub" android:layout="@layout/view_stub_failed_message_warning_layout" - app:layout_constraintBottom_toTopOf="@id/composerLayout" + app:layout_constraintBottom_toTopOf="@id/composerContainer" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" tools:layout_height="300dp" /> - - - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + + + app:constraint_referenced_ids="composerContainer,notificationAreaView,failedMessagesWarningStub" /> + From 2c9526543b6b0f139770ca4d76330c5080f4a25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Oct 2022 12:30:56 +0200 Subject: [PATCH 2/7] Remove coordinator, fix minor issues --- .../home/room/detail/TimelineFragment.kt | 31 +++++-------------- .../detail/composer/MessageComposerAction.kt | 7 +++-- .../composer/MessageComposerFragment.kt | 30 +++++++----------- .../composer/MessageComposerViewEvents.kt | 2 ++ .../composer/MessageComposerViewModel.kt | 13 +++++--- .../composer/MessageComposerViewState.kt | 11 ++++--- .../detail/timeline/item/AbsMessageItem.kt | 8 +---- 7 files changed, 41 insertions(+), 61 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index ae52d36c7e..fe4f33f37d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -129,7 +129,6 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.CanSendStatus -import im.vector.app.features.home.room.detail.composer.MessageComposer import im.vector.app.features.home.room.detail.composer.MessageComposerAction import im.vector.app.features.home.room.detail.composer.MessageComposerFragment import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel @@ -289,8 +288,6 @@ class TimelineFragment : private val lazyLoadedViews = RoomDetailLazyLoadedViews() - private lateinit var composer: MessageComposer - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) analyticsScreenName = MobileScreen.ScreenName.Room @@ -301,15 +298,13 @@ class TimelineFragment : } } - val composer = childFragmentManager.findFragmentById(R.id.composerContainer) as? MessageComposerFragment ?: run { - val fragment = MessageComposerFragment() - fragment.arguments = timelineArgs.toMvRxBundle() + childFragmentManager.findFragmentById(R.id.composerContainer) as? MessageComposerFragment ?: run { childFragmentManager.commitTransaction { + val fragment = MessageComposerFragment() + fragment.arguments = timelineArgs.toMvRxBundle() replace(R.id.composerContainer, fragment) } - fragment } - this.composer = composer childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) as? VoiceRecorderFragment ?: run { childFragmentManager.commitTransaction { @@ -970,7 +965,7 @@ class TimelineFragment : is RoomDetailPendingAction.JumpToReadReceipt -> timelineViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) is RoomDetailPendingAction.MentionUser -> - insertUserDisplayNameInTextEditor(roomDetailPendingAction.userId) + messageComposerViewModel.handle(MessageComposerAction.InsertUserDisplayName(roomDetailPendingAction.userId)) is RoomDetailPendingAction.OpenRoom -> handleOpenRoom(RoomDetailViewEvents.OpenRoom(roomDetailPendingAction.roomId, roomDetailPendingAction.closeCurrentRoom)) } @@ -1051,7 +1046,7 @@ class TimelineFragment : override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { (model as? AbsMessageItem)?.attributes?.informationData?.let { val eventId = it.eventId - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId, composer.getCurrentText().toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(eventId)) } } @@ -1573,7 +1568,7 @@ class TimelineFragment : } override fun onMemberNameClicked(informationData: MessageInformationData) { - insertUserDisplayNameInTextEditor(informationData.senderId) + messageComposerViewModel.handle(MessageComposerAction.InsertUserDisplayName(informationData.senderId)) } override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { @@ -1776,11 +1771,11 @@ class TimelineFragment : } } is EventSharedAction.Quote -> { - messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId, composer.getCurrentText().toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterQuoteMode(action.eventId)) } is EventSharedAction.Reply -> { if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { - messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId, composer.getCurrentText().toString())) + messageComposerViewModel.handle(MessageComposerAction.EnterReplyMode(action.eventId)) } else { requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } @@ -1881,16 +1876,6 @@ class TimelineFragment : .show() } - /** - * Insert a user displayName in the message editor. - * - * @param userId the userId. - */ - @SuppressLint("SetTextI18n") - private fun insertUserDisplayNameInTextEditor(userId: String) { - composer.insertUserDisplayNameInTextEditor(userId) - } - private fun showSnackWithMessage(message: String) { view?.showOptimizedSnackbar(message) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index ac39e0e915..97e6657fc2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -25,13 +25,14 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent sealed class MessageComposerAction : VectorViewModelAction { data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() data class EnterEditMode(val eventId: String) : MessageComposerAction() - data class EnterQuoteMode(val eventId: String, val text: String) : MessageComposerAction() - data class EnterReplyMode(val eventId: String, val text: String) : MessageComposerAction() - data class EnterRegularMode(val text: String, val fromSharing: Boolean) : MessageComposerAction() + data class EnterQuoteMode(val eventId: String) : MessageComposerAction() + data class EnterReplyMode(val eventId: String) : MessageComposerAction() + data class EnterRegularMode(val fromSharing: Boolean) : MessageComposerAction() data class UserIsTyping(val isTyping: Boolean) : MessageComposerAction() data class OnTextChanged(val text: CharSequence) : MessageComposerAction() data class OnEntersBackground(val composerText: String) : MessageComposerAction() data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() + data class InsertUserDisplayName(val userId: String) : MessageComposerAction() // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 103950b3b8..d78864d8d6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -22,7 +22,6 @@ import android.content.res.Configuration import android.net.Uri import android.os.Build import android.os.Bundle -import android.text.Editable import android.text.Spannable import android.text.format.DateUtils import android.view.KeyEvent @@ -112,13 +111,8 @@ import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber import javax.inject.Inject -interface MessageComposer { - fun getCurrentText(): Editable? - fun insertUserDisplayNameInTextEditor(userId: String) -} - @AndroidEntryPoint -class MessageComposerFragment : VectorBaseFragment(), MessageComposer, AttachmentsHelper.Callback, AttachmentTypeSelectorView.Callback { +class MessageComposerFragment : VectorBaseFragment(), AttachmentsHelper.Callback, AttachmentTypeSelectorView.Callback { companion object { private const val ircPattern = " (IRC)" @@ -196,6 +190,7 @@ class MessageComposerFragment : VectorBaseFragment(), M } showErrorInSnackbar(it.throwable) } + is MessageComposerViewEvents.InsertUserDisplayName -> insertUserDisplayNameInTextEditor(it.userId) } } @@ -204,10 +199,10 @@ class MessageComposerFragment : VectorBaseFragment(), M return@onEach } when (mode) { - is SendMode.Regular -> renderRegularMode(mode.text) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) + is SendMode.Regular -> renderRegularMode(mode.text.toString()) + is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text.toString()) + is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text.toString()) + is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text.toString()) is SendMode.Voice -> renderVoiceMessageMode(mode.text) } } @@ -304,7 +299,7 @@ class MessageComposerFragment : VectorBaseFragment(), M } override fun onCloseRelatedMessage() { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(views.composerLayout.text.toString(), false)) + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(false)) } override fun onRichContentSelected(contentUri: Uri): Boolean { @@ -723,7 +718,8 @@ class MessageComposerFragment : VectorBaseFragment(), M private fun handleShareData() { when (val sharedData = timelineArgs.sharedData) { is SharedData.Text -> { - messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) + messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(sharedData.text)) + messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(fromSharing = true)) } is SharedData.Attachments -> { // open share edition @@ -733,17 +729,13 @@ class MessageComposerFragment : VectorBaseFragment(), M } } - override fun getCurrentText(): Editable? { - return views.composerLayout.text - } - - override fun insertUserDisplayNameInTextEditor(userId: String) { + private fun insertUserDisplayNameInTextEditor(userId: String) { val startToCompose = views.composerLayout.text.isNullOrBlank() if (startToCompose && userId == session.myUserId) { // Empty composer, current user: start an emote - views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") + views.composerLayout.views.composerEditText.setText("${Command.EMOTE.command} ") views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) } else { val roomMember = timelineViewModel.getMember(userId) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt index e1f6923d21..3a949acb07 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt @@ -47,4 +47,6 @@ sealed class MessageComposerViewEvents : VectorViewEvents { data class ShowRoomUpgradeDialog(val newVersion: String, val isPublic: Boolean) : MessageComposerViewEvents() data class VoicePlaybackOrRecordingFailure(val throwable: Throwable) : MessageComposerViewEvents() + + data class InsertUserDisplayName(val userId: String) : MessageComposerViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index f9bf244eb1..afdd01ba46 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -113,6 +113,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action) is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action) is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(action) + is MessageComposerAction.InsertUserDisplayName -> handleInsertUserDisplayName(action) } } @@ -144,7 +145,7 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleEnterRegularMode(action: MessageComposerAction.EnterRegularMode) = setState { - copy(sendMode = SendMode.Regular(action.text, action.fromSharing)) + copy(sendMode = SendMode.Regular(currentComposerText, action.fromSharing)) } private fun handleEnterEditMode(action: MessageComposerAction.EnterEditMode) { @@ -181,13 +182,13 @@ class MessageComposerViewModel @AssistedInject constructor( private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.Quote(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Quote(timelineEvent, currentComposerText)) } } } private fun handleEnterReplyMode(action: MessageComposerAction.EnterReplyMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> - setState { copy(sendMode = SendMode.Reply(timelineEvent, action.text)) } + setState { copy(sendMode = SendMode.Reply(timelineEvent, currentComposerText)) } } } @@ -875,7 +876,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } } - handleEnterRegularMode(MessageComposerAction.EnterRegularMode(text = "", fromSharing = false)) + handleEnterRegularMode(MessageComposerAction.EnterRegularMode(fromSharing = false)) } private fun handlePlayOrPauseVoicePlayback(action: MessageComposerAction.PlayOrPauseVoicePlayback) { @@ -943,6 +944,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } + private fun handleInsertUserDisplayName(action: MessageComposerAction.InsertUserDisplayName) { + _viewEvents.post(MessageComposerViewEvents.InsertUserDisplayName(action.userId)) + } + private fun launchSlashCommandFlowSuspendable(parsedCommand: ParsedCommand, block: suspend () -> Unit) { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 5698414ab4..47a7122584 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -33,15 +33,15 @@ import kotlin.random.Random */ sealed interface SendMode { data class Regular( - val text: String, + val text: CharSequence, val fromSharing: Boolean, // This is necessary for forcing refresh on selectSubscribe private val random: Int = Random.nextInt() ) : SendMode - data class Quote(val timelineEvent: TimelineEvent, val text: String) : SendMode - data class Edit(val timelineEvent: TimelineEvent, val text: String) : SendMode - data class Reply(val timelineEvent: TimelineEvent, val text: String) : SendMode + data class Quote(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode + data class Edit(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode + data class Reply(val timelineEvent: TimelineEvent, val text: CharSequence) : SendMode data class Voice(val text: String) : SendMode } @@ -66,7 +66,8 @@ data class MessageComposerViewState( val rootThreadEventId: String? = null, val startsThread: Boolean = false, val sendMode: SendMode = SendMode.Regular("", false), - val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle + val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, + val text: CharSequence? = null, ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index 8dba0117b5..869b7d17e2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -63,12 +63,6 @@ abstract class AbsMessageItem( } } - private val _memberNameClickListener = object : ClickListener { - override fun invoke(p1: View) { - attributes.avatarCallback?.onMemberNameClicked(attributes.informationData) - } - } - private val _threadClickListener = object : ClickListener { override fun invoke(p1: View) { attributes.threadCallback?.onThreadSummaryClicked(attributes.informationData.eventId, attributes.threadDetails?.isRootThread ?: false) @@ -95,7 +89,7 @@ abstract class AbsMessageItem( holder.memberNameView.isVisible = true holder.memberNameView.text = attributes.informationData.memberName holder.memberNameView.setTextColor(attributes.getMemberNameColor()) - holder.memberNameView.onClick(_memberNameClickListener) + holder.memberNameView.onClick(attributes.memberClickListener) holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) } else { holder.memberNameView.setOnClickListener(null) From 0d97fa201ed0a0020f86ae69e78a04effbdb9096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Oct 2022 12:52:00 +0200 Subject: [PATCH 3/7] Try to centralise the usage of fragment args --- .../home/room/detail/RoomDetailViewState.kt | 7 ++++- .../home/room/detail/TimelineFragment.kt | 12 +++----- .../composer/MessageComposerFragment.kt | 28 +++++++++---------- .../composer/voice/VoiceRecorderFragment.kt | 6 +--- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 7aa7d5a877..897594ffad 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -20,6 +20,7 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.share.SharedData import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState @@ -77,6 +78,8 @@ data class RoomDetailViewState( val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState(), val typingUsers: List? = null, val isSharingLiveLocation: Boolean = false, + val showKeyboardWhenPresented: Boolean = false, + val sharedData: SharedData? = null, ) : MavericksState { constructor(args: TimelineArgs) : this( @@ -86,7 +89,9 @@ data class RoomDetailViewState( // Also highlight the target event, if any highlightedEventId = args.eventId, switchToParentSpace = args.switchToParentSpace, - rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId, + showKeyboardWhenPresented = args.threadTimelineArgs?.showKeyboard.orFalse(), + sharedData = args.sharedData, ) fun isCallOptionAvailable(): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index fe4f33f37d..4def538c46 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -300,17 +300,13 @@ class TimelineFragment : childFragmentManager.findFragmentById(R.id.composerContainer) as? MessageComposerFragment ?: run { childFragmentManager.commitTransaction { - val fragment = MessageComposerFragment() - fragment.arguments = timelineArgs.toMvRxBundle() - replace(R.id.composerContainer, fragment) + replace(R.id.composerContainer, MessageComposerFragment()) } } childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) as? VoiceRecorderFragment ?: run { childFragmentManager.commitTransaction { - val fragment = VoiceRecorderFragment() - fragment.arguments = timelineArgs.toMvRxBundle() - replace(R.id.voiceMessageRecorderContainer, fragment) + replace(R.id.voiceMessageRecorderContainer, VoiceRecorderFragment()) } } } @@ -2010,7 +2006,7 @@ class TimelineFragment : /** * Returns true if the current room is a Thread room, false otherwise. */ - private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } /** * Returns true if the current room is a local room, false otherwise. @@ -2020,5 +2016,5 @@ class TimelineFragment : /** * Returns the root thread event if we are in a thread room, otherwise returns null. */ - fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index d78864d8d6..8ec5f0d313 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -40,7 +40,6 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import com.airbnb.mvrx.args import com.airbnb.mvrx.existingViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -74,7 +73,6 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.AutoCompleter import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.TimelineViewModel -import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider @@ -131,14 +129,14 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var buildMeta: BuildMeta @Inject lateinit var session: Session - private val timelineArgs: TimelineArgs by args() + private val roomId: String get() = withState(timelineViewModel) { it.roomId } private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) + autoCompleterFactory.create(roomId, isThreadTimeLine()) } private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(timelineArgs.roomId) + pillsPostProcessorFactory.create(roomId) } private val emojiPopup: EmojiPopup by lifecycleAwareLazy { @@ -267,7 +265,8 @@ class MessageComposerFragment : VectorBaseFragment(), A views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() - if (isThreadTimeLine() && timelineArgs.threadTimelineArgs?.showKeyboard == true) { + val showKeyboard = withState(timelineViewModel) { it.showKeyboardWhenPresented } + if (isThreadTimeLine() && showKeyboard) { // Show keyboard when the user started a thread views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) } @@ -555,7 +554,7 @@ class MessageComposerFragment : VectorBaseFragment(), A views.composerLayout.setTextIfDifferent("") when (parsedCommand) { is ParsedCommand.DevTools -> { - navigator.openDevTools(requireContext(), timelineArgs.roomId) + navigator.openDevTools(requireContext(), roomId) } is ParsedCommand.SetMarkdown -> { showSnackWithMessage(getString(if (parsedCommand.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) @@ -578,12 +577,13 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { val tag = MigrateRoomBottomSheet::javaClass.name - MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) + val roomId = withState(timelineViewModel) { it.roomId } + MigrateRoomBottomSheet.newInstance(roomId, roomDetailViewEvents.newVersion) .show(parentFragmentManager, tag) } private fun openRoomMemberProfile(userId: String) { - navigator.openRoomMemberProfile(userId = userId, roomId = timelineArgs.roomId, context = requireActivity()) + navigator.openRoomMemberProfile(userId = userId, roomId = roomId, context = requireActivity()) } private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -598,12 +598,12 @@ class MessageComposerFragment : VectorBaseFragment(), A /** * Returns the root thread event if we are in a thread room, otherwise returns null. */ - fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } /** * Returns true if the current room is a Thread room, false otherwise. */ - private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } // AttachmentsHelper.Callback @@ -656,12 +656,12 @@ class MessageComposerFragment : VectorBaseFragment(), A AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) + AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE) AttachmentTypeSelectorView.Type.LOCATION -> { navigator .openLocationSharing( context = requireContext(), - roomId = timelineArgs.roomId, + roomId = roomId, mode = LocationSharingMode.STATIC_SHARING, initialLocationData = null, locationOwnerId = session.myUserId @@ -716,7 +716,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun handleShareData() { - when (val sharedData = timelineArgs.sharedData) { + when (val sharedData = withState(timelineViewModel) { it.sharedData }) { is SharedData.Text -> { messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(sharedData.text)) messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(fromSharing = true)) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt index 964331f924..c2c2af675c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -21,7 +21,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import com.airbnb.mvrx.args import com.airbnb.mvrx.existingViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint @@ -35,7 +34,6 @@ import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentVoiceRecorderBinding import im.vector.app.features.home.room.detail.TimelineViewModel -import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.MessageComposerAction import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel @@ -48,8 +46,6 @@ class VoiceRecorderFragment : VectorBaseFragment() @Inject lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker @Inject lateinit var clock: Clock - private val timelineArgs: TimelineArgs by args() - private val timelineViewModel: TimelineViewModel by existingViewModel() private val messageComposerViewModel: MessageComposerViewModel by existingViewModel() @@ -191,6 +187,6 @@ class VoiceRecorderFragment : VectorBaseFragment() /** * Returns the root thread event if we are in a thread room, otherwise returns null. */ - fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } } From 3c2e2552ec7d8a74e9e1cc113765983deddd89ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Oct 2022 12:57:18 +0200 Subject: [PATCH 4/7] Simplify child fragment replacement logic --- .../vector/app/features/home/room/detail/TimelineFragment.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 4def538c46..c9e8382a6b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -71,6 +71,7 @@ import im.vector.app.core.extensions.ensureEndsLeftToRight import im.vector.app.core.extensions.filterDirectionOverrides import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.extensions.trackItemsVisibilityChange @@ -298,13 +299,13 @@ class TimelineFragment : } } - childFragmentManager.findFragmentById(R.id.composerContainer) as? MessageComposerFragment ?: run { + if (childFragmentManager.findFragmentById(R.id.composerContainer) == null) { childFragmentManager.commitTransaction { replace(R.id.composerContainer, MessageComposerFragment()) } } - childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) as? VoiceRecorderFragment ?: run { + if (childFragmentManager.findFragmentById(R.id.voiceMessageRecorderContainer) == null) { childFragmentManager.commitTransaction { replace(R.id.voiceMessageRecorderContainer, VoiceRecorderFragment()) } From 321fddf5f66f55a3fa794fd1811fbaf4898d4233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Oct 2022 13:24:41 +0200 Subject: [PATCH 5/7] Remove TODO --- .../composer/MessageComposerFragment.kt | 23 ++++++++----------- .../composer/voice/VoiceRecorderFragment.kt | 4 ++-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 8ec5f0d313..369f8c2d74 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -437,8 +437,6 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun renderVoiceMessageMode(content: String) { ContentAttachmentData.fromJsonString(content)?.let { audioAttachmentData -> - // TODO: review this behaviour -// views.voiceMessageRecorderView.isVisible = true messageComposerViewModel.handle(MessageComposerAction.InitializeVoiceRecorder(audioAttachmentData)) } } @@ -595,17 +593,6 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - /** - * Returns the root thread event if we are in a thread room, otherwise returns null. - */ - fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } - - /** - * Returns true if the current room is a Thread room, false otherwise. - */ - private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } - - // AttachmentsHelper.Callback override fun onContentAttachmentsReady(attachments: List) { val grouped = attachments.toGroupedContentAttachmentData() @@ -787,6 +774,16 @@ class MessageComposerFragment : VectorBaseFragment(), A return displayName } + /** + * Returns the root thread event if we are in a thread room, otherwise returns null. + */ + fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } + + /** + * Returns true if the current room is a Thread room, false otherwise. + */ + private fun isThreadTimeLine(): Boolean = withState(timelineViewModel) { it.isThreadTimeline() } + /** Set whether the keyboard should disable personalized learning. */ @RequiresApi(Build.VERSION_CODES.O) private fun EditText.setUseIncognitoKeyboard(useIncognitoKeyboard: Boolean) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt index c2c2af675c..c5bb204ddb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -88,8 +88,9 @@ class VoiceRecorderFragment : VectorBaseFragment() override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> if (mainState.tombstoneEvent != null) return@withState + val hasVoiceDraft = messageComposerState.voiceRecordingUiState is VoiceMessageRecorderView.RecordingUiState.Draft with(views.root) { - isVisible = messageComposerState.isVoiceMessageRecorderVisible + isVisible = messageComposerState.isVoiceMessageRecorderVisible || hasVoiceDraft render(messageComposerState.voiceRecordingUiState) } } @@ -188,5 +189,4 @@ class VoiceRecorderFragment : VectorBaseFragment() * Returns the root thread event if we are in a thread room, otherwise returns null. */ fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } - } From e1cad01d54ef308bff93362870faefaf6d8e9667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 4 Oct 2022 13:39:14 +0200 Subject: [PATCH 6/7] Fix lint issues --- .../home/room/detail/TimelineFragment.kt | 2 - .../composer/MessageComposerFragment.kt | 68 +++++++++---------- .../composer/voice/VoiceRecorderFragment.kt | 21 +++--- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index c9e8382a6b..9f074a45b8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -71,9 +71,7 @@ import im.vector.app.core.extensions.ensureEndsLeftToRight import im.vector.app.core.extensions.filterDirectionOverrides import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.registerStartForActivityResult -import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequests diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 369f8c2d74..00f2c56c37 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.composer +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.content.res.Configuration @@ -249,15 +250,13 @@ class MessageComposerFragment : VectorBaseFragment(), A composerEditText.setOnEditorActionListener { v, actionId, keyEvent -> val imeActionId = actionId and EditorInfo.IME_MASK_ACTION - if (EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId) { - sendTextMessage(v.text) - true - } + val isSendAction = EditorInfo.IME_ACTION_DONE == imeActionId || EditorInfo.IME_ACTION_SEND == imeActionId // Add external keyboard functionality (to send messages) - else if (null != keyEvent && + val externalKeyboardPressedEnter = null != keyEvent && !keyEvent.isShiftPressed && keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && - resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) { + resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS + if (isSendAction || externalKeyboardPressedEnter) { sendTextMessage(v.text) true } else false @@ -716,6 +715,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + @SuppressLint("SetTextI18n") private fun insertUserDisplayNameInTextEditor(userId: String) { val startToCompose = views.composerLayout.text.isNullOrBlank() @@ -726,36 +726,32 @@ class MessageComposerFragment : VectorBaseFragment(), A views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) } else { val roomMember = timelineViewModel.getMember(userId) - // TODO move logic outside of fragment - sanitizeDisplayName(roomMember?.displayName ?: userId) - .let { displayName -> - buildSpannedString { - append(displayName) - setSpan( - PillImageSpan( - glideRequests, - avatarRenderer, - requireContext(), - MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) - ) - .also { it.bind(views.composerLayout.views.composerEditText) }, - 0, - displayName.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - append(if (startToCompose) ": " else " ") - }.let { pill -> - if (startToCompose) { - if (displayName.startsWith("/")) { - // Ensure displayName will not be interpreted as a Slash command - views.composerLayout.views.composerEditText.append("\\") - } - views.composerLayout.views.composerEditText.append(pill) - } else { - views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) - } - } - } + val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) + val pill = buildSpannedString { + append(displayName) + setSpan( + PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) + ) + .also { it.bind(views.composerLayout.views.composerEditText) }, + 0, + displayName.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(if (startToCompose) ": " else " ") + } + if (startToCompose) { + if (displayName.startsWith("/")) { + // Ensure displayName will not be interpreted as a Slash command + views.composerLayout.views.composerEditText.append("\\") + } + views.composerLayout.views.composerEditText.append(pill) + } else { + views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) + } } focusComposerAndShowKeyboard() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt index c5bb204ddb..c45410a5cc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -151,19 +151,9 @@ class VoiceRecorderFragment : VectorBaseFragment() updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Idle) } - override fun onRecordingLimitReached() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) - } + override fun onRecordingLimitReached() = pauseRecording() - override fun onRecordingWaveformClicked() { - messageComposerViewModel.handle( - MessageComposerAction.PauseRecordingVoiceMessage - ) - updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) - } + override fun onRecordingWaveformClicked() = pauseRecording() override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) { messageComposerViewModel.handle( @@ -182,6 +172,13 @@ class VoiceRecorderFragment : VectorBaseFragment() MessageComposerAction.OnVoiceRecordingUiStateChanged(state) ) } + + private fun pauseRecording() { + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage + ) + updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Draft) + } } } From e6a2d50b92982358e1696dfdddc6a4c1b82fcbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 5 Oct 2022 12:55:46 +0200 Subject: [PATCH 7/7] Add changelog, address review comments. --- changelog.d/7285.misc | 1 + .../home/room/detail/composer/MessageComposerFragment.kt | 6 +++--- .../room/detail/composer/voice/VoiceRecorderFragment.kt | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 changelog.d/7285.misc diff --git a/changelog.d/7285.misc b/changelog.d/7285.misc new file mode 100644 index 0000000000..ce94383146 --- /dev/null +++ b/changelog.d/7285.misc @@ -0,0 +1 @@ +Refactor TimelineFragment, split it into MessageComposerFragment and VoiceRecorderFragment. diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 00f2c56c37..21a87f092f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -41,7 +41,7 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import com.airbnb.mvrx.existingViewModel +import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.vanniktech.emoji.EmojiPopup @@ -156,8 +156,8 @@ class MessageComposerFragment : VectorBaseFragment(), A private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView - private val timelineViewModel: TimelineViewModel by existingViewModel() - private val messageComposerViewModel: MessageComposerViewModel by existingViewModel() + private val timelineViewModel: TimelineViewModel by activityViewModel() + private val messageComposerViewModel: MessageComposerViewModel by activityViewModel() private lateinit var sharedActionViewModel: MessageSharedActionViewModel override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentComposerBinding { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt index c45410a5cc..ef253f87a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -21,7 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import com.airbnb.mvrx.existingViewModel +import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R @@ -46,8 +46,8 @@ class VoiceRecorderFragment : VectorBaseFragment() @Inject lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker @Inject lateinit var clock: Clock - private val timelineViewModel: TimelineViewModel by existingViewModel() - private val messageComposerViewModel: MessageComposerViewModel by existingViewModel() + private val timelineViewModel: TimelineViewModel by activityViewModel() + private val messageComposerViewModel: MessageComposerViewModel by activityViewModel() private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) {