diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index 71f099c26..7b7d78a42 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -58,6 +58,7 @@ import androidx.appcompat.view.ContextThemeWrapper import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.emoji.text.EmojiCompat import androidx.emoji.widget.EmojiTextView +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.Data @@ -107,6 +108,8 @@ import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.mention.Mention import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.ui.dialog.AttachmentDialog +import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions +import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ConductorRemapping import com.nextcloud.talk.utils.ConductorRemapping.remapChatController @@ -448,6 +451,21 @@ class ChatController(args: Bundle) : adapter?.setDateHeadersFormatter { format(it) } adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) } + if (context != null) { + val messageSwipeController = MessageSwipeCallback( + activity!!, + object : MessageSwipeActions { + override fun showReplyUI(position: Int) { + val chatMessage = adapter?.items?.get(position)?.item as ChatMessage? + replyToMessage(chatMessage, chatMessage?.jsonMessageId) + } + } + ) + + val itemTouchHelper = ItemTouchHelper(messageSwipeController) + itemTouchHelper.attachToRecyclerView(messagesListView) + } + layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager? binding.popupBubbleView.setRecyclerView(binding.messagesListView) @@ -1357,10 +1375,8 @@ class ChatController(args: Bundle) : if (TextUtils.isEmpty(chatMessageList[i].systemMessage) && TextUtils.isEmpty(chatMessageList[i + 1].systemMessage) && chatMessageList[i + 1].actorId == chatMessageList[i].actorId && - countGroupedMessages < 4 && DateFormatter.isSameDay( - chatMessageList[i].createdAt, - chatMessageList[i + 1].createdAt - ) + countGroupedMessages < 4 && + DateFormatter.isSameDay(chatMessageList[i].createdAt, chatMessageList[i + 1].createdAt) ) { chatMessageList[i].isGrouped = true countGroupedMessages++ @@ -1624,58 +1640,7 @@ class ChatController(args: Bundle) : } R.id.action_reply_to_message -> { val chatMessage = message as ChatMessage? - chatMessage?.let { - binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = - View.GONE - binding.messageInputView.findViewById(R.id.attachmentButtonSpace)?.visibility = - View.GONE - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.visibility = - View.VISIBLE - - val quotedMessage = binding - .messageInputView - .findViewById(R.id.quotedMessage) - - quotedMessage?.maxLines = 2 - quotedMessage?.ellipsize = TextUtils.TruncateAt.END - quotedMessage?.text = it.text - binding.messageInputView.findViewById(R.id.quotedMessageAuthor)?.text = - it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest) - - conversationUser?.let { currentUser -> - val quotedMessageImage = binding - .messageInputView - .findViewById(R.id.quotedMessageImage) - chatMessage.imageUrl?.let { previewImageUrl -> - quotedMessageImage?.visibility = View.VISIBLE - - val px = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 96f, - resources?.displayMetrics - ) - - quotedMessageImage?.maxHeight = px.toInt() - val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams - layoutParams.flexGrow = 0f - quotedMessageImage.layoutParams = layoutParams - quotedMessageImage.load(previewImageUrl) { - addHeader("Authorization", credentials!!) - } - } ?: run { - binding - .messageInputView - .findViewById(R.id.quotedMessageImage) - ?.visibility = View.GONE - } - } - - val quotedChatMessageView = binding - .messageInputView - .findViewById(R.id.quotedChatMessageView) - quotedChatMessageView?.tag = message?.jsonMessageId - quotedChatMessageView?.visibility = View.VISIBLE - } + replyToMessage(chatMessage, message?.jsonMessageId) true } R.id.action_reply_privately -> { @@ -1820,6 +1785,61 @@ class ChatController(args: Bundle) : } } + private fun replyToMessage(chatMessage: ChatMessage?, jsonMessageId: Int?) { + chatMessage?.let { + binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = + View.GONE + binding.messageInputView.findViewById(R.id.attachmentButtonSpace)?.visibility = + View.GONE + binding.messageInputView.findViewById(R.id.cancelReplyButton)?.visibility = + View.VISIBLE + + val quotedMessage = binding + .messageInputView + .findViewById(R.id.quotedMessage) + + quotedMessage?.maxLines = 2 + quotedMessage?.ellipsize = TextUtils.TruncateAt.END + quotedMessage?.text = it.text + binding.messageInputView.findViewById(R.id.quotedMessageAuthor)?.text = + it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest) + + conversationUser?.let { currentUser -> + val quotedMessageImage = binding + .messageInputView + .findViewById(R.id.quotedMessageImage) + chatMessage.imageUrl?.let { previewImageUrl -> + quotedMessageImage?.visibility = View.VISIBLE + + val px = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 96f, + resources?.displayMetrics + ) + + quotedMessageImage?.maxHeight = px.toInt() + val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams + layoutParams.flexGrow = 0f + quotedMessageImage.layoutParams = layoutParams + quotedMessageImage.load(previewImageUrl) { + addHeader("Authorization", credentials!!) + } + } ?: run { + binding + .messageInputView + .findViewById(R.id.quotedMessageImage) + ?.visibility = View.GONE + } + } + + val quotedChatMessageView = binding + .messageInputView + .findViewById(R.id.quotedChatMessageView) + quotedChatMessageView?.tag = jsonMessageId + quotedChatMessageView?.visibility = View.VISIBLE + } + } + private fun setMessageAsDeleted(message: IMessage?) { val messageTemp = message as ChatMessage messageTemp.isDeleted = true diff --git a/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeActions.kt b/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeActions.kt new file mode 100644 index 000000000..d0ad28832 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeActions.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Talk application + * + * @author Shain Singh + * @author Andy Scherzinger + * Copyright (C) 2021 Shain Singh + * Copyright (C) 2021 Andy Scherzinger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Based on the MessageSwipeController by Shain Singh at: + * https://github.com/shainsingh89/SwipeToReply/blob/master/app/src/main/java/com/shain/messenger/SwipeControllerActions.kt + */ + +package com.nextcloud.talk.ui.recyclerview + +/** + * Actions executed within a swipe gesture. + */ +interface MessageSwipeActions { + + /** + * Display reply message including the original, quoted message of/at [position]. + */ + fun showReplyUI(position: Int) +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeCallback.kt b/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeCallback.kt new file mode 100644 index 000000000..3e62dbd61 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeCallback.kt @@ -0,0 +1,268 @@ +/* + * Nextcloud Talk application + * + * @author Shain Singh + * @author Andy Scherzinger + * Copyright (C) 2021 Shain Singh + * Copyright (C) 2021 Andy Scherzinger + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * Based on the MessageSwipeController by Shain Singh at: + * https://github.com/shainsingh89/SwipeToReply/blob/master/app/src/main/java/com/shain/messenger/MessageSwipeController.kt + */ + +package com.nextcloud.talk.ui.recyclerview + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.util.Log +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_IDLE +import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_SWIPE +import androidx.recyclerview.widget.ItemTouchHelper.RIGHT +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.talk.R +import com.nextcloud.talk.adapters.messages.MagicIncomingTextMessageViewHolder +import com.nextcloud.talk.adapters.messages.MagicOutcomingTextMessageViewHolder +import com.nextcloud.talk.adapters.messages.MagicPreviewMessageViewHolder +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.min + +/** + * Callback implementation for swipe-right-gesture on messages. + * + * @property context activity's context to load resources like drawables. + * @property messageSwipeActions the actions to be executed upon swipe-right. + * @constructor Creates as swipe-right callback for messages + */ +class MessageSwipeCallback(private val context: Context, private val messageSwipeActions: MessageSwipeActions) : + ItemTouchHelper.Callback() { + + companion object { + const val TAG = "MessageSwipeCallback" + } + + private var density = 1f + + private lateinit var imageDrawable: Drawable + private lateinit var shareRound: Drawable + + private var currentItemViewHolder: RecyclerView.ViewHolder? = null + private lateinit var view: View + private var dX = 0f + + private var replyButtonProgress: Float = 0.toFloat() + private var lastReplyButtonAnimationTime: Long = 0 + private var swipeBack = false + private var isVibrate = false + private var startTracking = false + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + if (viewHolder is MagicPreviewMessageViewHolder || + viewHolder is MagicIncomingTextMessageViewHolder || + viewHolder is MagicOutcomingTextMessageViewHolder + ) { + view = viewHolder.itemView + imageDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_reply)!! + shareRound = AppCompatResources.getDrawable(context, R.drawable.round_bgnd)!! + return makeMovementFlags(ACTION_STATE_IDLE, RIGHT) + } + + // disable swiping any other message type + return 0 + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun convertToAbsoluteDirection(flags: Int, layoutDirection: Int): Int { + if (swipeBack) { + swipeBack = false + return 0 + } + return super.convertToAbsoluteDirection(flags, layoutDirection) + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + + if (actionState == ACTION_STATE_SWIPE) { + setTouchListener(recyclerView, viewHolder) + } + + if (view.translationX < convertToDp(130) || dX < this.dX) { + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + this.dX = dX + startTracking = true + } + currentItemViewHolder = viewHolder + drawReplyButton(c) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setTouchListener(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + recyclerView.setOnTouchListener { _, event -> + swipeBack = event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP + if (swipeBack) { + if (abs(view.translationX) >= this@MessageSwipeCallback.convertToDp(100)) { + messageSwipeActions.showReplyUI(viewHolder.adapterPosition) + } + } + false + } + } + + private fun drawReplyButton(canvas: Canvas) { + if (currentItemViewHolder == null) { + return + } + val translationX = view.translationX + val newTime = System.currentTimeMillis() + val dt = min(17, newTime - lastReplyButtonAnimationTime) + lastReplyButtonAnimationTime = newTime + val showing = translationX >= convertToDp(30) + if (showing) { + if (replyButtonProgress < 1.0f) { + replyButtonProgress += dt / 180.0f + if (replyButtonProgress > 1.0f) { + replyButtonProgress = 1.0f + } else { + view.invalidate() + } + } + } else if (translationX <= 0.0f) { + replyButtonProgress = 0f + startTracking = false + isVibrate = false + } else { + if (replyButtonProgress > 0.0f) { + replyButtonProgress -= dt / 180.0f + if (replyButtonProgress < 0.1f) { + replyButtonProgress = 0f + } else { + view.invalidate() + } + } + } + + val alpha: Int + val scale: Float + if (showing) { + scale = if (replyButtonProgress <= 0.8f) { + 1.2f * (replyButtonProgress / 0.8f) + } else { + 1.2f - 0.2f * ((replyButtonProgress - 0.8f) / 0.2f) + } + alpha = min(255f, 255 * (replyButtonProgress / 0.8f)).toInt() + } else { + scale = replyButtonProgress + alpha = min(255f, 255 * replyButtonProgress).toInt() + } + shareRound.alpha = alpha + imageDrawable.alpha = alpha + + if (startTracking) { + if (!isVibrate && view.translationX >= convertToDp(100)) { + view.performHapticFeedback( + HapticFeedbackConstants.KEYBOARD_TAP, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + isVibrate = true + } + } + + val x: Int = if (view.translationX > convertToDp(130)) { + convertToDp(130) / 2 + } else { + (view.translationX / 2).toInt() + } + + val y = (view.top + view.measuredHeight / 2).toFloat() + shareRound.colorFilter = PorterDuffColorFilter( + ContextCompat.getColor(context, R.color.bg_message_list_incoming_bubble), + PorterDuff.Mode.SRC_IN + ) + imageDrawable.colorFilter = PorterDuffColorFilter( + ContextCompat.getColor(context, R.color.high_emphasis_text), + PorterDuff.Mode.SRC_IN + ) + + shareRound.setBounds( + (x - convertToDp(18) * scale).toInt(), + (y - convertToDp(18) * scale).toInt(), + (x + convertToDp(18) * scale).toInt(), + (y + convertToDp(18) * scale).toInt() + ) + shareRound.draw(canvas) + + imageDrawable.setBounds( + (x - convertToDp(12) * scale).toInt(), + (y - convertToDp(13) * scale).toInt(), + (x + convertToDp(12) * scale).toInt(), + (y + convertToDp(11) * scale).toInt() + ) + imageDrawable.draw(canvas) + + shareRound.alpha = 255 + imageDrawable.alpha = 255 + } + + private fun convertToDp(pixel: Int): Int { + return dp(pixel.toFloat(), context) + } + + private fun dp(value: Float, context: Context): Int { + if (density == 1f) { + checkDisplaySize(context) + } + return if (value == 0f) { + 0 + } else { + ceil((density * value).toDouble()).toInt() + } + } + + private fun checkDisplaySize(context: Context) { + try { + density = context.resources.displayMetrics.density + } catch (e: Exception) { + Log.w(TAG, "Error calculating density", e) + } + } +}