From 4b57907040b9a854de57e7815ef9a853c539e2c1 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 14 Jul 2026 12:51:20 -0500 Subject: [PATCH 1/2] Refactoring message drafts to use new source of truth fixing previous unsynchronized state bug Signed-off-by: rapterjet2004 --- .gitignore | 3 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 10 +- .../talk/chat/MessageInputFragment.kt | 247 ++++++++++++------ .../chat/viewmodels/MessageInputViewModel.kt | 98 +++++++ .../main/res/layout/create_thread_view.xml | 3 +- .../main/res/layout/view_message_input.xml | 3 +- 6 files changed, 270 insertions(+), 94 deletions(-) diff --git a/.gitignore b/.gitignore index 98b5bf608ec..18c4d68678c 100644 --- a/.gitignore +++ b/.gitignore @@ -82,10 +82,11 @@ freeline_project_description.json **/fastlane/test_output /fastlane/vendor/ /.bundle/ +/.artifacts/ /fastlane/.bundle # python **/__pycache__/ /gradle/verification-keyring.gpg -/.claude/settings.local.json +/.claude/settings.local.json \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 2b622b7a804..facd3a22cc3 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2820,7 +2820,7 @@ class ChatActivity : ) } - public override fun onDestroy() { + override fun onDestroy() { super.onDestroy() logConversationInfos("onDestroy") @@ -4029,15 +4029,11 @@ class ChatActivity : } fun cancelReply() { - messageInputViewModel.reply(null) - chatViewModel.messageDraft.quotedMessageText = null - chatViewModel.messageDraft.quotedDisplayName = null - chatViewModel.messageDraft.quotedImageUrl = null - chatViewModel.messageDraft.quotedJsonId = null + messageInputViewModel.cancelReply() } fun cancelCreateThread() { - chatViewModel.clearThreadTitle() + messageInputViewModel.cancelCreateThread() } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 9dbf2600bfe..e5580ae3709 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.chat +import android.content.Context import android.content.res.Resources import android.graphics.drawable.Drawable import android.os.Build @@ -29,6 +30,7 @@ import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.Animation.AnimationListener import android.view.animation.LinearInterpolator +import android.view.inputmethod.InputMethodManager import android.widget.Chronometer import android.widget.ImageButton import android.widget.ImageView @@ -38,6 +40,7 @@ import android.widget.RelativeLayout import android.widget.SeekBar 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 @@ -89,11 +92,10 @@ import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.text.Spans import com.otaliastudios.autocomplete.Autocomplete -import kotlinx.coroutines.Dispatchers +import com.vanniktech.emoji.EmojiPopup import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.Objects import javax.inject.Inject @@ -125,6 +127,9 @@ class MessageInputFragment : Fragment() { private var typedWhileTypingTimerIsRunning: Boolean = false private var typingTimer: CountDownTimer? = null private lateinit var chatActivity: ChatActivity + private var emojiPopup: EmojiPopup? = null + private var isEmojiPopupOpen = false + private var restoreKeyboardOnEmojiDismiss = false private var mentionAutocomplete: Autocomplete<*>? = null private var xcounter = 0f private var ycounter = 0f @@ -133,6 +138,10 @@ class MessageInputFragment : Fragment() { private lateinit var spreedCapabilities: SpreedCapability private var hasSharedText = false + private var lastQuotedJsonId: Int? = null + private var lastEditMessageId: Int? = null + private var lastIsThreadCreationInProgress: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) @@ -156,10 +165,20 @@ class MessageInputFragment : Fragment() { } override fun onDestroyView() { + restoreKeyboardOnEmojiDismiss = false + emojiPopup?.dismiss() + emojiPopup = null + isEmojiPopupOpen = false super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } + + val messageText = binding.fragmentMessageInputView.messageInput.text + if (messageText.isNotEmpty()) { + chatActivity.chatViewModel.messageDraft.messageText = messageText.toString() + chatActivity.chatViewModel.saveMessageDraft() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -167,12 +186,69 @@ class MessageInputFragment : Fragment() { initObservers() binding.fragmentCreateThreadView.createThreadView.findViewById( - R.id - .createThreadInput + R.id.createThreadInput ).doAfterTextChanged { text -> val threadTitle = text.toString() + messageInputViewModel.updateThreadTitle(threadTitle) chatActivity.chatViewModel.messageDraft.threadTitle = threadTitle } + + observeInputState() + } + + private fun observeInputState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + messageInputViewModel.inputState.collect { state -> + updateUiFromState(state) + chatActivity.chatViewModel.messageDraft = messageInputViewModel.toDraft() + } + } + } + } + + private fun updateUiFromState(state: MessageInputViewModel.MessageInputState) { + val input = binding.fragmentMessageInputView.messageInput + val currentText = input.text.toString() + if (currentText != state.messageText) { + input.setText(state.messageText) + input.setSelection(state.messageCursor.coerceAtMost(state.messageText.length)) + } + + if (state.quotedJsonId != lastQuotedJsonId) { + lastQuotedJsonId = state.quotedJsonId + if (state.quotedJsonId != null) { + replyToMessage( + state.quotedMessageText, + state.quotedDisplayName, + state.quotedImageUrl + ) + } else { + clearReplyUi() + } + } + + val editMessageId = state.editMessage?.jsonMessageId + if (editMessageId != lastEditMessageId) { + lastEditMessageId = editMessageId + if (state.editMessage != null) { + setEditUI(state.editMessage) + } else { + clearEditUI() + } + } + + if (state.isThreadCreationInProgress != lastIsThreadCreationInProgress) { + lastIsThreadCreationInProgress = state.isThreadCreationInProgress + binding.fragmentCreateThreadView.createThreadView.isVisible = state.isThreadCreationInProgress + } + + val threadInput = binding.fragmentCreateThreadView.createThreadView.findViewById( + R.id.createThreadInput + ) + if (threadInput.text.toString() != state.threadTitle) { + threadInput.setText(state.threadTitle) + } } @Suppress("LongMethod") @@ -182,6 +258,7 @@ class MessageInputFragment : Fragment() { when (state) { is ChatViewModel.GetCapabilitiesUpdateState -> { initMessageInputView(state.spreedCapabilities) + initSmileyKeyboardToggler() setupMentionAutocomplete() initVoiceRecordButton() initThreadHandling() @@ -190,33 +267,18 @@ class MessageInputFragment : Fragment() { is ChatViewModel.GetCapabilitiesInitialLoadState -> { initMessageInputView(state.spreedCapabilities) + initSmileyKeyboardToggler() setupMentionAutocomplete() initVoiceRecordButton() initThreadHandling() updateScheduledMessagesAvailability(hasScheduledMessages) - restoreState() + initInputState() } else -> {} } } - messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message -> - message?.let { - chatActivity.chatViewModel.messageDraft.quotedMessageText = message.getRichText() - chatActivity.chatViewModel.messageDraft.quotedDisplayName = message.actorDisplayName - // chatActivity.chatViewModel.messageDraft.quotedImageUrl = message.imageUrl - chatActivity.chatViewModel.messageDraft.quotedImageUrl = "" // TODO - chatActivity.chatViewModel.messageDraft.quotedJsonId = message.jsonMessageId - replyToMessage( - quotedMessageText = message.getRichText(), - quotedActorDisplayName = message.actorDisplayName, - // quotedImageUrl = message.imageUrl - quotedImageUrl = "" // TODO - ) - } ?: clearReplyUi() - } - chatActivity.chatViewModel.scheduledMessagesCount.observe(viewLifecycleOwner) { count -> if (chatActivity.conversationThreadId != null && chatActivity.conversationThreadId!! > 0) { val threadId = chatActivity.conversationThreadId @@ -232,32 +294,6 @@ class MessageInputFragment : Fragment() { } } - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.CREATED) { - messageInputViewModel.getEditChatMessage.collect { message -> - message?.let { setEditUI(it) } ?: clearEditUI() - } - } - } - - messageInputViewModel.createThreadViewState.observe(viewLifecycleOwner) { state -> - when (state) { - is MessageInputViewModel.CreateThreadStartState -> - binding.fragmentCreateThreadView.createThreadView.visibility = View.GONE - - is MessageInputViewModel.CreateThreadEditState -> { - binding.fragmentCreateThreadView.createThreadView.visibility = View.VISIBLE - binding.fragmentCreateThreadView.createThreadView - .findViewById(R.id.createThreadInput)?.setText( - chatActivity.chatViewModel.messageDraft.threadTitle - ) - } - - else -> {} - } - initVoiceRecordButton() - } - chatActivity.chatViewModel.leaveRoomViewState.observe(viewLifecycleOwner) { state -> when (state) { is ChatViewModel.LeaveRoomSuccessState -> sendStopTypingMessage() @@ -382,32 +418,16 @@ class MessageInputFragment : Fragment() { cancelCreateThread() } - private fun restoreState() { - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + private fun initInputState() { + viewLifecycleOwner.lifecycleScope.launch { if (!hasSharedText) { chatActivity.chatViewModel.updateMessageDraft() } - - withContext(Dispatchers.Main) { - val draft = chatActivity.chatViewModel.messageDraft - binding.fragmentMessageInputView.messageInput.setText(draft.messageText) - binding.fragmentMessageInputView.messageInput.setSelection(draft.messageCursor) - - if (draft.threadTitle?.isNotEmpty() == true) { - messageInputViewModel.startThreadCreation() - } - - if (draft.messageText != "") { - binding.fragmentMessageInputView.messageInput.requestFocus() - } - - if (isInReplyState()) { - replyToMessage( - chatActivity.chatViewModel.messageDraft.quotedMessageText, - chatActivity.chatViewModel.messageDraft.quotedDisplayName, - chatActivity.chatViewModel.messageDraft.quotedImageUrl - ) - } + if (messageInputViewModel.inputState.value.messageText.isEmpty() && + messageInputViewModel.inputState.value.quotedJsonId == null && + messageInputViewModel.inputState.value.editMessage == null + ) { + messageInputViewModel.initFromDraft(chatActivity.chatViewModel.messageDraft) } } } @@ -473,6 +493,9 @@ class MessageInputFragment : Fragment() { override fun afterTextChanged(s: Editable) { val cursor = binding.fragmentMessageInputView.messageInput.selectionStart val text = binding.fragmentMessageInputView.messageInput.text.toString() + messageInputViewModel.updateMessageText(text) + messageInputViewModel.updateMessageCursor(cursor) + chatActivity.chatViewModel.messageDraft.messageCursor = cursor chatActivity.chatViewModel.messageDraft.messageText = text handleButtonsVisibility() @@ -814,6 +837,8 @@ class MessageInputFragment : Fragment() { private fun showRecordAudioUi(show: Boolean) { if (show) { + restoreKeyboardOnEmojiDismiss = false + emojiPopup?.dismiss() val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT) animation.duration = ANIMATION_DURATION animation.interpolator = LinearInterpolator() @@ -826,6 +851,7 @@ class MessageInputFragment : Fragment() { 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 = "" binding.fragmentMessageInputView.scheduledMessagesButton.visibility = View.GONE @@ -837,12 +863,69 @@ class MessageInputFragment : Fragment() { 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 updateSmileyButtonIcon() { + val icon = if (isEmojiPopupOpen) { + R.drawable.ic_baseline_keyboard_24 + } else { + R.drawable.ic_insert_emoticon_black_24dp + } + val drawable = ContextCompat.getDrawable(requireContext(), icon) + binding.fragmentMessageInputView.smileyButton.setImageDrawable(drawable) + } + + private fun showKeyboard() { + val editText = binding.fragmentMessageInputView.inputEditText + editText.requestFocus() + val inputMethodManager = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + inputMethodManager?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + + private fun initSmileyKeyboardToggler() { + val inputEditText = binding.fragmentMessageInputView.inputEditText + if (emojiPopup == null) { + emojiPopup = EmojiPopup( + rootView = binding.root, + editText = inputEditText, + onEmojiPopupShownListener = { + isEmojiPopupOpen = true + updateSmileyButtonIcon() + }, + onEmojiPopupDismissListener = { + isEmojiPopupOpen = false + updateSmileyButtonIcon() + if (restoreKeyboardOnEmojiDismiss) { + restoreKeyboardOnEmojiDismiss = false + showKeyboard() + } + }, + onEmojiClickListener = { + inputEditText.editableText?.append(" ") + } + ) + } + + updateSmileyButtonIcon() + + binding.fragmentMessageInputView.smileyButton.setOnClickListener { + if (isEmojiPopupOpen) { + restoreKeyboardOnEmojiDismiss = true + emojiPopup?.dismiss() + } else { + restoreKeyboardOnEmojiDismiss = false + inputEditText.requestFocus() + emojiPopup?.toggle() + } + } + } + private fun replyToMessage(quotedMessageText: String?, quotedActorDisplayName: String?, quotedImageUrl: String?) { Log.d(TAG, "Reply") val view = binding.fragmentMessageInputView @@ -1106,12 +1189,15 @@ class MessageInputFragment : Fragment() { } private fun clearEditUI() { - binding.fragmentMessageInputView.editMessageButton.visibility = View.GONE - binding.fragmentMessageInputView.inputEditText.setText("") binding.fragmentEditView.editMessageView.visibility = View.GONE + binding.fragmentMessageInputView.messageSendButton.visibility = View.VISIBLE + binding.fragmentMessageInputView.recordAudioButton.visibility = View.VISIBLE + binding.fragmentMessageInputView.submitThreadButton.visibility = View.VISIBLE + binding.fragmentMessageInputView.editMessageButton.visibility = View.GONE binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE - messageInputViewModel.edit(null) - handleButtonsVisibility() + binding.fragmentMessageInputView.scheduledMessagesButton.visibility = View.VISIBLE + messageInputViewModel.cancelEdit() + lastEditMessageId = null } private fun themeMessageInputView() { @@ -1192,25 +1278,18 @@ class MessageInputFragment : Fragment() { } private fun cancelCreateThread() { - chatActivity.cancelCreateThread() - messageInputViewModel.stopThreadCreation() - binding.fragmentCreateThreadView.createThreadView.visibility = View.GONE + messageInputViewModel.cancelCreateThread() } private fun cancelReply() { - chatActivity.cancelReply() - clearReplyUi() + messageInputViewModel.cancelReply() } private fun clearReplyUi() { val quote = binding.fragmentMessageInputView.findViewById(R.id.quotedChatMessageView) quote.visibility = View.GONE binding.fragmentMessageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE - } - - private fun isInReplyState(): Boolean { - val jsonId = chatActivity.chatViewModel.messageDraft.quotedJsonId - return jsonId != null + lastQuotedJsonId = null } companion object { diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt index e6128411dea..5f38d1b79f1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -23,12 +23,15 @@ import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource +import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage +import com.nextcloud.talk.models.json.chat.ChatUtils import com.nextcloud.talk.utils.message.SendMessageUtils import io.reactivex.disposables.Disposable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -38,6 +41,18 @@ class MessageInputViewModel : ViewModel(), DefaultLifecycleObserver { + data class MessageInputState( + val messageText: String = "", + val messageCursor: Int = 0, + val quotedJsonId: Int? = null, + val quotedDisplayName: String? = null, + val quotedMessageText: String? = null, + val quotedImageUrl: String? = null, + val threadTitle: String? = null, + val editMessage: ChatMessage? = null, + val isThreadCreationInProgress: Boolean = false + ) + enum class LifeCycleFlag { PAUSED, RESUMED, @@ -234,14 +249,95 @@ class MessageInputViewModel : } } + private val _inputState = MutableStateFlow(MessageInputState()) + val inputState: StateFlow = _inputState + + fun updateMessageText(text: String) { + _inputState.update { it.copy(messageText = text) } + } + + fun updateMessageCursor(cursor: Int) { + _inputState.update { it.copy(messageCursor = cursor) } + } + + fun updateThreadTitle(title: String) { + _inputState.update { it.copy(threadTitle = title) } + } + + fun initFromDraft(draft: MessageDraft) { + _inputState.update { + it.copy( + messageText = draft.messageText, + messageCursor = draft.messageCursor, + quotedJsonId = draft.quotedJsonId, + quotedDisplayName = draft.quotedDisplayName, + quotedMessageText = draft.quotedMessageText, + quotedImageUrl = draft.quotedImageUrl, + threadTitle = draft.threadTitle, + isThreadCreationInProgress = false + ) + } + } + + fun toDraft(): MessageDraft = + MessageDraft( + messageText = _inputState.value.messageText, + messageCursor = _inputState.value.messageCursor, + quotedJsonId = _inputState.value.quotedJsonId, + quotedDisplayName = _inputState.value.quotedDisplayName, + quotedMessageText = _inputState.value.quotedMessageText, + quotedImageUrl = _inputState.value.quotedImageUrl, + threadTitle = _inputState.value.threadTitle + ) + fun reply(message: ChatMessage?) { + _inputState.update { + it.copy( + quotedJsonId = message?.jsonMessageId, + quotedDisplayName = message?.actorDisplayName, + quotedMessageText = message?.getRichText(), + quotedImageUrl = "" // TODO + ) + } _getReplyChatMessage.postValue(message) } + fun cancelReply() { + _inputState.update { + it.copy( + quotedJsonId = null, + quotedDisplayName = null, + quotedMessageText = null, + quotedImageUrl = null + ) + } + _getReplyChatMessage.postValue(null) + } + fun edit(message: ChatMessage?) { + val text = message?.let { + ChatUtils.getParsedMessage(it.message, it.messageParameters) + } ?: "" + _inputState.update { + it.copy( + editMessage = message, + messageText = text, + messageCursor = text.length + ) + } _getEditChatMessage.value = message } + fun cancelEdit() { + _inputState.update { it.copy(editMessage = null) } + _getEditChatMessage.value = null + } + + fun cancelCreateThread() { + _inputState.update { it.copy(threadTitle = "") } + stopThreadCreation() + } + fun startMicInput(context: Context) { audioFocusRequestManager.audioFocusRequest(true) { audioRecorderManager.start(context) @@ -284,10 +380,12 @@ class MessageInputViewModel : } fun startThreadCreation() { + _inputState.update { it.copy(isThreadCreationInProgress = true) } _createThreadViewState.postValue(CreateThreadEditState()) } fun stopThreadCreation() { + _inputState.update { it.copy(isThreadCreationInProgress = false) } _createThreadViewState.postValue(CreateThreadStartState) } diff --git a/app/src/main/res/layout/create_thread_view.xml b/app/src/main/res/layout/create_thread_view.xml index 5f1f629515d..9a1d7ced1a3 100644 --- a/app/src/main/res/layout/create_thread_view.xml +++ b/app/src/main/res/layout/create_thread_view.xml @@ -10,7 +10,8 @@ android:id="@+id/createThreadView" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="horizontal"> + android:orientation="horizontal" + android:visibility="gone"> + app:tint="?attr/colorControlNormal" + android:visibility="gone" /> Date: Fri, 7 Aug 2026 10:45:51 -0500 Subject: [PATCH 2/2] moving away from vanniktech emoji picker Signed-off-by: rapterjet2004 --- .../talk/chat/MessageInputFragment.kt | 89 ++++++++++++------- .../com/nextcloud/talk/ui/MessageInput.kt | 2 + .../ui/theme/TalkSpecificViewThemeUtils.kt | 13 +++ .../res/layout/fragment_message_input.xml | 6 ++ .../main/res/layout/view_message_input.xml | 14 ++- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index e5580ae3709..83264cb25f8 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -44,6 +44,7 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged +import androidx.emoji2.emojipicker.RecentEmojiProvider import androidx.emoji2.widget.EmojiTextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -92,7 +93,6 @@ import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.text.Spans import com.otaliastudios.autocomplete.Autocomplete -import com.vanniktech.emoji.EmojiPopup import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -127,8 +127,6 @@ class MessageInputFragment : Fragment() { private var typedWhileTypingTimerIsRunning: Boolean = false private var typingTimer: CountDownTimer? = null private lateinit var chatActivity: ChatActivity - private var emojiPopup: EmojiPopup? = null - private var isEmojiPopupOpen = false private var restoreKeyboardOnEmojiDismiss = false private var mentionAutocomplete: Autocomplete<*>? = null private var xcounter = 0f @@ -166,9 +164,6 @@ class MessageInputFragment : Fragment() { override fun onDestroyView() { restoreKeyboardOnEmojiDismiss = false - emojiPopup?.dismiss() - emojiPopup = null - isEmojiPopupOpen = false super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() @@ -838,7 +833,7 @@ class MessageInputFragment : Fragment() { private fun showRecordAudioUi(show: Boolean) { if (show) { restoreKeyboardOnEmojiDismiss = false - emojiPopup?.dismiss() + binding.emojiPicker.isVisible = false val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT) animation.duration = ANIMATION_DURATION animation.interpolator = LinearInterpolator() @@ -871,7 +866,7 @@ class MessageInputFragment : Fragment() { } private fun updateSmileyButtonIcon() { - val icon = if (isEmojiPopupOpen) { + val icon = if (binding.emojiPicker.isVisible) { R.drawable.ic_baseline_keyboard_24 } else { R.drawable.ic_insert_emoticon_black_24dp @@ -888,44 +883,70 @@ class MessageInputFragment : Fragment() { inputMethodManager?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) } + private fun hideKeyboard() { + val view = activity?.currentFocus + if (view != null) { + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(view.windowToken, 0) + } + } + private fun initSmileyKeyboardToggler() { val inputEditText = binding.fragmentMessageInputView.inputEditText - if (emojiPopup == null) { - emojiPopup = EmojiPopup( - rootView = binding.root, - editText = inputEditText, - onEmojiPopupShownListener = { - isEmojiPopupOpen = true - updateSmileyButtonIcon() - }, - onEmojiPopupDismissListener = { - isEmojiPopupOpen = false - updateSmileyButtonIcon() - if (restoreKeyboardOnEmojiDismiss) { - restoreKeyboardOnEmojiDismiss = false - showKeyboard() - } - }, - onEmojiClickListener = { - inputEditText.editableText?.append(" ") - } - ) + val emojiPicker = binding.emojiPicker + + emojiPicker.setRecentEmojiProvider(MessageInputRecentEmojiProvider(requireContext())) + emojiPicker.setOnEmojiPickedListener { item -> + val start = inputEditText.selectionStart + val end = inputEditText.selectionEnd + inputEditText.editableText.replace(start, end, item.emoji) } + viewThemeUtils.talk.themeEmojiPicker(emojiPicker) updateSmileyButtonIcon() binding.fragmentMessageInputView.smileyButton.setOnClickListener { - if (isEmojiPopupOpen) { - restoreKeyboardOnEmojiDismiss = true - emojiPopup?.dismiss() + if (emojiPicker.isVisible) { + showKeyboard() + emojiPicker.isVisible = false } else { - restoreKeyboardOnEmojiDismiss = false - inputEditText.requestFocus() - emojiPopup?.toggle() + hideKeyboard() + emojiPicker.isVisible = true + } + updateSmileyButtonIcon() + } + + inputEditText.setOnClickListener { + if (emojiPicker.isVisible) { + emojiPicker.isVisible = false + updateSmileyButtonIcon() } } } + private class MessageInputRecentEmojiProvider(context: Context) : RecentEmojiProvider { + private val prefs = context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) + + override fun recordSelection(emoji: String) { + val updated = listOf(emoji) + getStoredList().filterNot { it == emoji } + prefs.edit() + .putString("recent", updated.take(MAX_STORED_RECENT_EMOJIS).joinToString(",")) + .apply() + } + + override suspend fun getRecentEmojiList(): List = getStoredList() + + private fun getStoredList(): List = + prefs.getString("recent", null) + ?.split(",") + ?.filter { it.isNotBlank() } + ?: emptyList() + + companion object { + private const val MAX_STORED_RECENT_EMOJIS = 20 + } + } + private fun replyToMessage(quotedMessageText: String?, quotedActorDisplayName: String?, quotedImageUrl: String?) { Log.d(TAG, "Reply") val view = binding.fragmentMessageInputView diff --git a/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt b/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt index 5c30b385284..eaad8c81d0c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt @@ -26,6 +26,7 @@ class MessageInput : FrameLayout { lateinit var slideToCancelDescription: TextView lateinit var microphoneEnabledInfo: ImageView lateinit var microphoneEnabledInfoBackground: ImageView + lateinit var smileyButton: ImageButton lateinit var deleteVoiceRecording: ImageView lateinit var sendVoiceRecording: ImageView lateinit var micInputCloud: MicInputCloud @@ -70,6 +71,7 @@ class MessageInput : FrameLayout { slideToCancelDescription = findViewById(R.id.slideToCancelDescription) microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo) microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground) + smileyButton = findViewById(R.id.smileyButton) deleteVoiceRecording = findViewById(R.id.deleteVoiceRecording) sendVoiceRecording = findViewById(R.id.sendVoiceRecording) micInputCloud = findViewById(R.id.micInputCloud) diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt index 78fd8801fbf..457c35a963e 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt @@ -35,6 +35,7 @@ import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.ColorUtils import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.ViewCompat +import androidx.emoji2.emojipicker.EmojiPickerView import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView import com.google.android.material.chip.Chip @@ -492,6 +493,18 @@ class TalkSpecificViewThemeUtils @Inject constructor( } } + fun themeEmojiPicker(emojiPickerView: EmojiPickerView) { + withScheme(emojiPickerView.context) { scheme -> + emojiPickerView.setBackgroundColor(dynamicColor.surfaceContainerLow().getArgb(scheme)) + themeEmojiPickerCategoryTabs( + emojiPickerView, + dynamicColor.primary().getArgb(scheme), + dynamicColor.onSurfaceVariant().getArgb(scheme) + ) + protectEmojiPickerScrollGesture(emojiPickerView) + } + } + companion object { private val THEMEABLE_PLACEHOLDER_IDS = listOf( R.drawable.ic_mimetype_package_x_generic, diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index 63f3c12e18c..741218e0392 100644 --- a/app/src/main/res/layout/fragment_message_input.xml +++ b/app/src/main/res/layout/fragment_message_input.xml @@ -71,6 +71,12 @@ android:animateLayoutChanges="true" android:inputType="textLongMessage|textAutoComplete" android:maxLength="1000" /> + + diff --git a/app/src/main/res/layout/view_message_input.xml b/app/src/main/res/layout/view_message_input.xml index 7472996a78b..6de63816a62 100644 --- a/app/src/main/res/layout/view_message_input.xml +++ b/app/src/main/res/layout/view_message_input.xml @@ -44,13 +44,25 @@ android:src="@drawable/ic_baseline_attach_file_24" app:tint="?attr/colorControlNormal" /> + +