/* * Nextcloud Talk - Android Client * * SPDX-FileCopyrightText: 2024 Julius Linus juliuslinus1@gmail.com * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.chat import android.content.res.Resources import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.CountDownTimer import android.os.SystemClock import android.text.Editable import android.text.InputFilter import android.text.TextUtils import android.text.TextWatcher import android.util.Log import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.Animation.AnimationListener import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.RelativeLayout import android.widget.SeekBar import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.emoji2.widget.EmojiTextView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import coil.Coil.imageLoader import coil.load import coil.request.ImageRequest import coil.target.Target import coil.transform.CircleCropTransformation import com.google.android.flexbox.FlexboxLayout import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.callbacks.MentionAutocompleteCallback import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.databinding.FragmentMessageInputBinding import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.json.mention.Mention import com.nextcloud.talk.models.json.signaling.NCSignalingMessage import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CharPolicy import com.nextcloud.talk.utils.ImageEmojiEditText import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.text.Spans import com.otaliastudios.autocomplete.Autocomplete import com.stfalcon.chatkit.commons.models.IMessage import com.vanniktech.emoji.EmojiPopup import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.util.Objects import javax.inject.Inject @Suppress("LongParameterList", "TooManyFunctions") @AutoInjector(NextcloudTalkApplication::class) class MessageInputFragment : Fragment() { companion object { fun newInstance() = MessageInputFragment() private val TAG: String = MessageInputFragment::class.java.simpleName private const val TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping" private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping" const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}" private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 private const val ANIMATION_DURATION: Long = 750 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -150 private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f private const val INCREMENT = 8f private const val CURSOR_KEY = "_cursor" private const val CONNECTION_ESTABLISHED_ANIM_DURATION: Long = 3000 private const val FULLY_OPAQUE: Float = 1.0f private const val FULLY_TRANSPARENT: Float = 0.0f } @Inject lateinit var viewThemeUtils: ViewThemeUtils @Inject lateinit var userManager: UserManager @Inject lateinit var currentUserProvider: CurrentUserProviderNew @Inject lateinit var networkMonitor: NetworkMonitor lateinit var binding: FragmentMessageInputBinding private lateinit var conversationInternalId: String private var typedWhileTypingTimerIsRunning: Boolean = false private var typingTimer: CountDownTimer? = null private lateinit var chatActivity: ChatActivity private var emojiPopup: EmojiPopup? = null private var mentionAutocomplete: Autocomplete<*>? = null private var xcounter = 0f private var ycounter = 0f private var collapsed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) conversationInternalId = arguments?.getString(ChatActivity.CONVERSATION_INTERNAL_ID).orEmpty() if (conversationInternalId.isEmpty()) { Log.e(TAG, "internalId for conversation passed to MessageInputFragment is empty") } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentMessageInputBinding.inflate(inflater) chatActivity = requireActivity() as ChatActivity themeMessageInputView() initMessageInputView() initSmileyKeyboardToggler() setupMentionAutocomplete() initVoiceRecordButton() restoreState() return binding.root } override fun onPause() { super.onPause() saveState() } override fun onDestroyView() { super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } clearEditUI() cancelReply() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initObservers() } private fun initObservers() { Log.d(TAG, "LifeCyclerOwner is: ${viewLifecycleOwner.lifecycle}") chatActivity.messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message -> message?.let { replyToMessage(message) } } chatActivity.messageInputViewModel.getEditChatMessage.observe(viewLifecycleOwner) { message -> message?.let { setEditUI(it as ChatMessage) } } chatActivity.chatViewModel.leaveRoomViewState.observe(viewLifecycleOwner) { state -> when (state) { is ChatViewModel.LeaveRoomSuccessState -> sendStopTypingMessage() else -> {} } } viewLifecycleOwner.lifecycleScope.launch { var wasOnline: Boolean networkMonitor.isOnline .onEach { isOnline -> wasOnline = !binding.fragmentConnectionLost.isShown val connectionGained = (!wasOnline && isOnline) Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained") if (connectionGained) { chatActivity.messageInputViewModel.sendUnsentMessages( chatActivity.conversationUser!!.getCredentials(), ApiUtils.getUrlForChat( chatActivity.chatApiVersion, chatActivity.conversationUser!!.baseUrl!!, chatActivity.roomToken ) ) } handleUI(isOnline, connectionGained) }.collect() } chatActivity.messageInputViewModel.callStartedFlow.observe(viewLifecycleOwner) { val (message, show) = it if (show) { binding.fragmentCallStarted.callAuthorChip.text = message.actorDisplayName binding.fragmentCallStarted.callAuthorChipSecondary.text = message.actorDisplayName val user = currentUserProvider.currentUser.blockingGet() val url: String = if (message.actorType == "guests" || message.actorType == "guest") { ApiUtils.getUrlForGuestAvatar(user!!.baseUrl!!, message.actorDisplayName, true) } else { ApiUtils.getUrlForAvatar(user!!.baseUrl!!, message.actorId, false) } val imageRequest: ImageRequest = ImageRequest.Builder(requireContext()) .data(url) .crossfade(true) .transformations(CircleCropTransformation()) .target(object : Target { override fun onStart(placeholder: Drawable?) { // unused atm } override fun onError(error: Drawable?) { // unused atm } override fun onSuccess(result: Drawable) { binding.fragmentCallStarted.callAuthorChip.chipIcon = result binding.fragmentCallStarted.callAuthorChipSecondary.chipIcon = result } }) .build() imageLoader(requireContext()).enqueue(imageRequest) binding.fragmentCallStarted.root.visibility = View.VISIBLE } else { binding.fragmentCallStarted.root.visibility = View.GONE } } } private fun handleUI(isOnline: Boolean, connectionGained: Boolean) { if (isOnline) { if (connectionGained) { val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT) animation.duration = CONNECTION_ESTABLISHED_ANIM_DURATION animation.interpolator = LinearInterpolator() binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityGreen)) binding.fragmentConnectionLost.text = getString(R.string.connection_established) binding.fragmentConnectionLost.startAnimation(animation) binding.fragmentConnectionLost.animation.setAnimationListener(object : AnimationListener { override fun onAnimationStart(animation: Animation?) { // unused atm } override fun onAnimationEnd(animation: Animation?) { binding.fragmentConnectionLost.visibility = View.GONE binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed)) binding.fragmentConnectionLost.text = getString(R.string.connection_lost_sent_messages_are_queued) } override fun onAnimationRepeat(animation: Animation?) { // unused atm } }) } binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE binding.fragmentMessageInputView.recordAudioButton.visibility = if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) View.VISIBLE else View.GONE } else { binding.fragmentMessageInputView.attachmentButton.visibility = View.INVISIBLE binding.fragmentMessageInputView.recordAudioButton.visibility = View.INVISIBLE binding.fragmentConnectionLost.clearAnimation() binding.fragmentConnectionLost.visibility = View.GONE binding.fragmentConnectionLost.setBackgroundColor(resources.getColor(R.color.hwSecurityRed)) binding.fragmentConnectionLost.visibility = View.VISIBLE } } private fun restoreState() { if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) { requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply { val text = getString(chatActivity.roomToken, "") val cursor = getInt(chatActivity.roomToken + CURSOR_KEY, 0) binding.fragmentMessageInputView.messageInput.setText(text) binding.fragmentMessageInputView.messageInput.setSelection(cursor) } } } private fun saveState() { val text = binding.fragmentMessageInputView.messageInput.text.toString() val cursor = binding.fragmentMessageInputView.messageInput.selectionStart val previous = requireContext().getSharedPreferences( chatActivity.localClassName, AppCompatActivity .MODE_PRIVATE ).getString(chatActivity.roomToken, "null") if (text != previous) { requireContext().getSharedPreferences( chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE ).edit().apply { putString(chatActivity.roomToken, text) putInt(chatActivity.roomToken + CURSOR_KEY, cursor) apply() } } } private fun initMessageInputView() { if (!chatActivity.active) return val filters = arrayOfNulls(1) val lengthFilter = CapabilitiesUtil.getMessageMaxLength(chatActivity.spreedCapabilities) binding.fragmentEditView.editMessageView.visibility = View.GONE binding.fragmentMessageInputView.setPadding(0, 0, 0, 0) filters[0] = InputFilter.LengthFilter(lengthFilter) binding.fragmentMessageInputView.inputEditText?.filters = filters binding.fragmentMessageInputView.inputEditText?.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { // unused atm } @Suppress("Detekt.TooGenericExceptionCaught") override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { updateOwnTypingStatus(s) if (s.length >= lengthFilter) { binding.fragmentMessageInputView.inputEditText?.error = String.format( Objects.requireNonNull(resources).getString(R.string.nc_limit_hit), lengthFilter.toString() ) } else { binding.fragmentMessageInputView.inputEditText?.error = null } val editable = binding.fragmentMessageInputView.inputEditText?.editableText if (editable != null && binding.fragmentMessageInputView.inputEditText != null) { val mentionSpans = editable.getSpans( 0, binding.fragmentMessageInputView.inputEditText!!.length(), Spans.MentionChipSpan::class.java ) var mentionSpan: Spans.MentionChipSpan for (i in mentionSpans.indices) { mentionSpan = mentionSpans[i] if (start >= editable.getSpanStart(mentionSpan) && start < editable.getSpanEnd(mentionSpan) ) { if (editable.subSequence( editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan) ).toString().trim() != mentionSpan.label ) { editable.removeSpan(mentionSpan) } } } } } override fun afterTextChanged(s: Editable) { // unused atm } }) // Image keyboard support // See: https://developer.android.com/guide/topics/text/image-keyboard (binding.fragmentMessageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = { uploadFile(it.toString(), false) } if (chatActivity.sharedText.isNotEmpty()) { binding.fragmentMessageInputView.inputEditText?.setText(chatActivity.sharedText) } binding.fragmentMessageInputView.setAttachmentsListener { AttachmentDialog(requireActivity(), requireActivity() as ChatActivity).show() } binding.fragmentMessageInputView.attachmentButton.setOnLongClickListener { chatActivity.showGalleryPicker() true } binding.fragmentMessageInputView.button?.setOnClickListener { submitMessage(false) } binding.fragmentMessageInputView.editMessageButton.setOnClickListener { val text = binding.fragmentMessageInputView.inputEditText.text.toString() val message = chatActivity.messageInputViewModel.getEditChatMessage.value as ChatMessage if (message.message!!.trim() != text.trim()) { editMessageAPI(message, text) } clearEditUI() } binding.fragmentEditView.clearEdit.setOnClickListener { clearEditUI() } if (CapabilitiesUtil.hasSpreedFeatureCapability(chatActivity.spreedCapabilities, SpreedFeatures.SILENT_SEND)) { binding.fragmentMessageInputView.button?.setOnLongClickListener { showSendButtonMenu() true } } binding.fragmentMessageInputView.button?.contentDescription = resources.getString(R.string.nc_description_send_message_button) binding.fragmentCallStarted.joinAudioCall.setOnClickListener { chatActivity.joinAudioCall() } binding.fragmentCallStarted.joinVideoCall.setOnClickListener { chatActivity.joinVideoCall() } binding.fragmentCallStarted.callStartedCloseBtn.setOnClickListener { collapsed = !collapsed binding.fragmentCallStarted.callAuthorLayout.visibility = if (collapsed) View.GONE else View.VISIBLE binding.fragmentCallStarted.callBtnLayout.visibility = if (collapsed) View.GONE else View.VISIBLE binding.fragmentCallStarted.callAuthorChipSecondary.visibility = if (collapsed) View.VISIBLE else View.GONE binding.fragmentCallStarted.callStartedSecondaryText.visibility = if (collapsed) View.VISIBLE else View.GONE setDropDown(collapsed) } } private fun setDropDown(collapsed: Boolean) { val drawable = if (collapsed) { AppCompatResources.getDrawable(requireContext(), R.drawable.ic_keyboard_arrow_up) } else { AppCompatResources.getDrawable(requireContext(), R.drawable.ic_keyboard_arrow_down) } binding.fragmentCallStarted.callStartedCloseBtn.setImageDrawable(drawable) } @Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod") private fun initVoiceRecordButton() { if (binding.fragmentMessageInputView.messageInput.text.isNullOrBlank()) { binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE binding.fragmentMessageInputView.recordAudioButton.visibility = View.VISIBLE } else { binding.fragmentMessageInputView.messageSendButton.visibility = View.VISIBLE binding.fragmentMessageInputView.recordAudioButton.visibility = View.GONE } binding.fragmentMessageInputView.inputEditText.doAfterTextChanged { binding.fragmentMessageInputView.recordAudioButton.visibility = if (binding.fragmentMessageInputView.inputEditText.text.isEmpty() && chatActivity.messageInputViewModel.getEditChatMessage.value == null ) { View.VISIBLE } else { View.GONE } binding.fragmentMessageInputView.messageSendButton.visibility = if (binding.fragmentMessageInputView.inputEditText.text.isEmpty() || binding.fragmentEditView.editMessageView.isVisible ) { View.GONE } else { View.VISIBLE } } var prevDx = 0f var voiceRecordStartTime = 0L var voiceRecordEndTime: Long binding.fragmentMessageInputView.recordAudioButton.setOnTouchListener { v, event -> v?.performClick() when (event?.action) { MotionEvent.ACTION_DOWN -> { if (!chatActivity.isRecordAudioPermissionGranted()) { chatActivity.requestRecordAudioPermissions() return@setOnTouchListener true } if (!chatActivity.permissionUtil.isFilesPermissionGranted()) { UploadAndShareFilesWorker.requestStoragePermission(chatActivity) return@setOnTouchListener true } val base = SystemClock.elapsedRealtime() voiceRecordStartTime = System.currentTimeMillis() binding.fragmentMessageInputView.audioRecordDuration.base = base chatActivity.messageInputViewModel.setRecordingTime(base) binding.fragmentMessageInputView.audioRecordDuration.start() chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) showRecordAudioUi(true) } MotionEvent.ACTION_CANCEL -> { Log.d(TAG, "ACTION_CANCEL") if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || !chatActivity.isRecordAudioPermissionGranted() ) { return@setOnTouchListener true } showRecordAudioUi(false) if (chatActivity.chatViewModel.getVoiceRecordingLocked.value != true) { // can also be null chatActivity.chatViewModel.stopAndDiscardAudioRecording() } } MotionEvent.ACTION_UP -> { Log.d(TAG, "ACTION_UP") if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || chatActivity.chatViewModel.getVoiceRecordingLocked.value == true || !chatActivity.isRecordAudioPermissionGranted() ) { return@setOnTouchListener false } showRecordAudioUi(false) voiceRecordEndTime = System.currentTimeMillis() val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { Snackbar.make( binding.root, requireContext().getString(R.string.nc_voice_message_hold_to_record_info), Snackbar.LENGTH_SHORT ).show() chatActivity.chatViewModel.stopAndDiscardAudioRecording() return@setOnTouchListener false } else { chatActivity.chatViewModel.stopAndSendAudioRecording( chatActivity.roomToken, chatActivity.currentConversation!!.displayName, VOICE_MESSAGE_META_DATA ) } resetSlider() } MotionEvent.ACTION_MOVE -> { if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || !chatActivity.isRecordAudioPermissionGranted() ) { return@setOnTouchListener false } if (event.x < VOICE_RECORD_CANCEL_SLIDER_X) { chatActivity.chatViewModel.stopAndDiscardAudioRecording() showRecordAudioUi(false) resetSlider() return@setOnTouchListener true } if (event.x < 0f) { val dX = event.x if (dX < prevDx) { // left binding.fragmentMessageInputView.slideToCancelDescription.x -= INCREMENT xcounter += INCREMENT } else { // right binding.fragmentMessageInputView.slideToCancelDescription.x += INCREMENT xcounter -= INCREMENT } prevDx = dX } if (event.y < 0f) { chatActivity.chatViewModel.postToRecordTouchObserver(INCREMENT) ycounter += INCREMENT } if (ycounter >= VOICE_RECORD_LOCK_THRESHOLD) { resetSlider() binding.fragmentMessageInputView.recordAudioButton.isEnabled = false chatActivity.chatViewModel.setVoiceRecordingLocked(true) binding.fragmentMessageInputView.recordAudioButton.isEnabled = true } } } v?.onTouchEvent(event) ?: true } } private fun resetSlider() { binding.fragmentMessageInputView.audioRecordDuration.stop() binding.fragmentMessageInputView.audioRecordDuration.clearAnimation() binding.fragmentMessageInputView.slideToCancelDescription.x += xcounter chatActivity.chatViewModel.postToRecordTouchObserver(-ycounter) xcounter = 0f ycounter = 0f } private fun setupMentionAutocomplete() { val elevation = MENTION_AUTO_COMPLETE_ELEVATION resources.let { val backgroundDrawable = it.getColor(R.color.bg_default, null).toDrawable() val presenter = MentionAutocompletePresenter( requireContext(), chatActivity.roomToken, chatActivity.chatApiVersion ) val callback = MentionAutocompleteCallback( requireContext(), chatActivity.conversationUser!!, binding.fragmentMessageInputView.inputEditText, viewThemeUtils ) if (mentionAutocomplete == null && binding.fragmentMessageInputView.inputEditText != null) { mentionAutocomplete = Autocomplete.on(binding.fragmentMessageInputView.inputEditText) .with(elevation) .with(backgroundDrawable) .with(CharPolicy('@')) .with(presenter) .with(callback) .build() } } } private fun showRecordAudioUi(show: Boolean) { if (show) { val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT) animation.duration = ANIMATION_DURATION animation.interpolator = LinearInterpolator() animation.repeatCount = Animation.INFINITE animation.repeatMode = Animation.REVERSE binding.fragmentMessageInputView.microphoneEnabledInfo.startAnimation(animation) binding.fragmentMessageInputView.microphoneEnabledInfo.visibility = View.VISIBLE binding.fragmentMessageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE binding.fragmentMessageInputView.audioRecordDuration.visibility = View.VISIBLE binding.fragmentMessageInputView.slideToCancelDescription.visibility = View.VISIBLE binding.fragmentMessageInputView.attachmentButton.visibility = View.GONE binding.fragmentMessageInputView.smileyButton.visibility = View.GONE binding.fragmentMessageInputView.messageInput.visibility = View.GONE binding.fragmentMessageInputView.messageInput.hint = "" } else { binding.fragmentMessageInputView.microphoneEnabledInfo.clearAnimation() binding.fragmentMessageInputView.microphoneEnabledInfo.visibility = View.GONE binding.fragmentMessageInputView.microphoneEnabledInfoBackground.visibility = View.GONE binding.fragmentMessageInputView.audioRecordDuration.visibility = View.GONE binding.fragmentMessageInputView.slideToCancelDescription.visibility = View.GONE binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE binding.fragmentMessageInputView.smileyButton.visibility = View.VISIBLE binding.fragmentMessageInputView.messageInput.visibility = View.VISIBLE binding.fragmentMessageInputView.messageInput.hint = requireContext().resources?.getString(R.string.nc_hint_enter_a_message) } } private fun initSmileyKeyboardToggler() { val smileyButton = binding.fragmentMessageInputView.findViewById(R.id.smileyButton) emojiPopup = binding.fragmentMessageInputView.inputEditText?.let { EmojiPopup( rootView = binding.root, editText = it, onEmojiPopupShownListener = { smileyButton?.setImageDrawable( ContextCompat.getDrawable(requireContext(), R.drawable.ic_baseline_keyboard_24) ) }, onEmojiPopupDismissListener = { smileyButton?.setImageDrawable( ContextCompat.getDrawable(requireContext(), R.drawable.ic_insert_emoticon_black_24dp) ) }, onEmojiClickListener = { binding.fragmentMessageInputView.inputEditText?.editableText?.append(" ") } ) } smileyButton?.setOnClickListener { emojiPopup?.toggle() } } private fun replyToMessage(message: IMessage?) { Log.d(TAG, "Reply") val chatMessage = message as ChatMessage? chatMessage?.let { val view = binding.fragmentMessageInputView view.findViewById(R.id.attachmentButton)?.visibility = View.GONE view.findViewById(R.id.cancelReplyButton)?.visibility = View.VISIBLE val quotedMessage = view.findViewById(R.id.quotedMessage) quotedMessage?.maxLines = 2 quotedMessage?.ellipsize = TextUtils.TruncateAt.END quotedMessage?.text = it.text view.findViewById(R.id.quotedMessageAuthor)?.text = it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest) chatActivity.conversationUser?.let { val quotedMessageImage = view.findViewById(R.id.quotedMessageImage) chatMessage.imageUrl?.let { previewImageUrl -> quotedMessageImage?.visibility = View.VISIBLE val px = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, resources.displayMetrics ) quotedMessageImage?.maxHeight = px.toInt() val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams layoutParams.flexGrow = 0f quotedMessageImage.layoutParams = layoutParams quotedMessageImage.load(previewImageUrl) { addHeader("Authorization", chatActivity.credentials!!) } } ?: run { view.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE } } val quotedChatMessageView = view.findViewById(R.id.quotedChatMessageView) quotedChatMessageView?.tag = message?.jsonMessageId quotedChatMessageView?.visibility = View.VISIBLE } } fun updateOwnTypingStatus(typedText: CharSequence) { fun sendStartTypingSignalingMessage() { val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() if (concurrentSafeHashMap != null) { for ((sessionId, _) in concurrentSafeHashMap) { val ncSignalingMessage = NCSignalingMessage() ncSignalingMessage.to = sessionId ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE chatActivity.signalingMessageSender!!.send(ncSignalingMessage) } } } if (isTypingStatusEnabled()) { if (typedText.isEmpty()) { sendStopTypingMessage() } else if (typingTimer == null) { sendStartTypingSignalingMessage() typingTimer = object : CountDownTimer( TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE, TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE ) { override fun onTick(millisUntilFinished: Long) { // unused } override fun onFinish() { if (typedWhileTypingTimerIsRunning) { sendStartTypingSignalingMessage() cancel() start() typedWhileTypingTimerIsRunning = false } else { sendStopTypingMessage() } } }.start() } else { typedWhileTypingTimerIsRunning = true } } } private fun sendStopTypingMessage() { if (isTypingStatusEnabled()) { typingTimer = null typedWhileTypingTimerIsRunning = false val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() if (concurrentSafeHashMap != null) { for ((sessionId, _) in concurrentSafeHashMap) { val ncSignalingMessage = NCSignalingMessage() ncSignalingMessage.to = sessionId ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE chatActivity.signalingMessageSender?.send(ncSignalingMessage) } } } } private fun isTypingStatusEnabled(): Boolean = !CapabilitiesUtil.isTypingStatusPrivate(chatActivity.conversationUser!!) private fun uploadFile(fileUri: String, isVoiceMessage: Boolean, caption: String = "", token: String = "") { var metaData = "" val room: String if (!chatActivity.participantPermissions.hasChatPermission()) { Log.w(ChatActivity.TAG, "uploading file(s) is forbidden because of missing attendee permissions") return } if (isVoiceMessage) { metaData = VOICE_MESSAGE_META_DATA } if (caption != "") { metaData = "{\"caption\":\"$caption\"}" } if (token == "") room = chatActivity.roomToken else room = token chatActivity.chatViewModel.uploadFile(fileUri, room, chatActivity.currentConversation!!.displayName, metaData) } private fun submitMessage(sendWithoutNotification: Boolean) { if (binding.fragmentMessageInputView.inputEditText != null) { val editable = binding.fragmentMessageInputView.inputEditText!!.editableText val mentionSpans = editable.getSpans( 0, editable.length, Spans.MentionChipSpan::class.java ) var mentionSpan: Spans.MentionChipSpan for (i in mentionSpans.indices) { mentionSpan = mentionSpans[i] var mentionId = mentionSpan.id val shouldQuote = mentionId.contains(" ") || mentionId.contains("@") || mentionId.startsWith("guest/") || mentionId.startsWith("group/") || mentionId.startsWith("email/") || mentionId.startsWith("team/") if (shouldQuote) { mentionId = "\"" + mentionId + "\"" } editable.replace(editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId") } binding.fragmentMessageInputView.inputEditText?.setText("") sendStopTypingMessage() val replyMessageId = binding.fragmentMessageInputView .findViewById(R.id.quotedChatMessageView)?.tag as Int? ?: 0 sendMessage( editable.toString(), replyMessageId, sendWithoutNotification ) cancelReply() } } private fun sendMessage(message: String, replyTo: Int?, sendWithoutNotification: Boolean) { chatActivity.messageInputViewModel.sendChatMessage( chatActivity.conversationUser!!.getCredentials(), ApiUtils.getUrlForChat( chatActivity.chatApiVersion, chatActivity.conversationUser!!.baseUrl!!, chatActivity.roomToken ), message, chatActivity.conversationUser!!.displayName ?: "", replyTo ?: 0, sendWithoutNotification ) } private fun showSendButtonMenu() { val popupMenu = PopupMenu( ContextThemeWrapper(requireContext(), R.style.ChatSendButtonMenu), binding.fragmentMessageInputView.button, Gravity.END ) popupMenu.inflate(R.menu.chat_send_menu) popupMenu.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.send_without_notification -> submitMessage(true) } true } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { popupMenu.setForceShowIcon(true) } popupMenu.show() } private fun editMessageAPI(message: ChatMessage, editedMessageText: String) { // FIXME Fix API checking with guests? val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1)) if (message.isTemporary) { chatActivity.messageInputViewModel.editTempChatMessage( message, editedMessageText ) } else { chatActivity.messageInputViewModel.editChatMessage( chatActivity.credentials!!, ApiUtils.getUrlForChatMessage( apiVersion, chatActivity.conversationUser!!.baseUrl!!, chatActivity.roomToken, message.id ), editedMessageText ) } } private fun setEditUI(message: ChatMessage) { binding.fragmentEditView.editMessage.text = message.message binding.fragmentMessageInputView.inputEditText.setText(message.message) val end = binding.fragmentMessageInputView.inputEditText.text.length binding.fragmentMessageInputView.inputEditText.setSelection(end) binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE binding.fragmentMessageInputView.recordAudioButton.visibility = View.GONE binding.fragmentMessageInputView.editMessageButton.visibility = View.VISIBLE binding.fragmentEditView.editMessageView.visibility = View.VISIBLE binding.fragmentMessageInputView.attachmentButton.visibility = View.GONE } private fun clearEditUI() { binding.fragmentMessageInputView.editMessageButton.visibility = View.GONE binding.fragmentMessageInputView.inputEditText.setText("") binding.fragmentEditView.editMessageView.visibility = View.GONE binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE chatActivity.messageInputViewModel.edit(null) } private fun themeMessageInputView() { binding.fragmentMessageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentMessageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { cancelReply() } binding.fragmentMessageInputView.findViewById(R.id.cancelReplyButton)?.let { viewThemeUtils.platform .themeImageButton(it) } binding.fragmentMessageInputView.findViewById(R.id.playPauseBtn)?.let { viewThemeUtils.material.colorMaterialButtonText(it) } binding.fragmentMessageInputView.findViewById(R.id.seekbar)?.let { viewThemeUtils.platform.themeHorizontalSeekBar(it) } binding.fragmentMessageInputView.findViewById(R.id.deleteVoiceRecording)?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentMessageInputView.findViewById(R.id.sendVoiceRecording)?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentMessageInputView.findViewById(R.id.microphoneEnabledInfo)?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentMessageInputView.findViewById(R.id.voice_preview_container)?.let { viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false) } binding.fragmentMessageInputView.findViewById(R.id.micInputCloud)?.let { viewThemeUtils.talk.themeMicInputCloud(it) } binding.fragmentMessageInputView.findViewById(R.id.editMessageButton)?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentEditView.clearEdit.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } binding.fragmentCallStarted.callStartedBackground.apply { viewThemeUtils.talk.themeOutgoingMessageBubble(this, grouped = true, false) } binding.fragmentCallStarted.callAuthorChip.apply { viewThemeUtils.material.colorChipBackground(this) } binding.fragmentCallStarted.callAuthorChipSecondary.apply { viewThemeUtils.material.colorChipBackground(this) } binding.fragmentCallStarted.callStartedCloseBtn.apply { viewThemeUtils.platform.colorImageView(this, ColorRole.PRIMARY) } } private fun cancelReply() { val quote = binding.fragmentMessageInputView .findViewById(R.id.quotedChatMessageView) quote.visibility = View.GONE quote.tag = null binding.fragmentMessageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE chatActivity.messageInputViewModel.reply(null) } }