Эх сурвалжийг харах

Merge pull request #1313 from nextcloud/swipeToReply

Swipe-right to reply
Andy Scherzinger 3 жил өмнө
parent
commit
3e17bf31ff

+ 76 - 56
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(binding.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<ImageButton>(R.id.attachmentButton)?.visibility =
-                                View.GONE
-                            binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility =
-                                View.GONE
-                            binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
-                                View.VISIBLE
-
-                            val quotedMessage = binding
-                                .messageInputView
-                                .findViewById<EmojiTextView>(R.id.quotedMessage)
-
-                            quotedMessage?.maxLines = 2
-                            quotedMessage?.ellipsize = TextUtils.TruncateAt.END
-                            quotedMessage?.text = it.text
-                            binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
-                                it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest)
-
-                            conversationUser?.let { currentUser ->
-                                val quotedMessageImage = binding
-                                    .messageInputView
-                                    .findViewById<ImageView>(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<ImageView>(R.id.quotedMessageImage)
-                                        ?.visibility = View.GONE
-                                }
-                            }
-
-                            val quotedChatMessageView = binding
-                                .messageInputView
-                                .findViewById<RelativeLayout>(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<ImageButton>(R.id.attachmentButton)?.visibility =
+                View.GONE
+            binding.messageInputView.findViewById<Space>(R.id.attachmentButtonSpace)?.visibility =
+                View.GONE
+            binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
+                View.VISIBLE
+
+            val quotedMessage = binding
+                .messageInputView
+                .findViewById<EmojiTextView>(R.id.quotedMessage)
+
+            quotedMessage?.maxLines = 2
+            quotedMessage?.ellipsize = TextUtils.TruncateAt.END
+            quotedMessage?.text = it.text
+            binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
+                it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest)
+
+            conversationUser?.let { currentUser ->
+                val quotedMessageImage = binding
+                    .messageInputView
+                    .findViewById<ImageView>(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<ImageView>(R.id.quotedMessageImage)
+                        ?.visibility = View.GONE
+                }
+            }
+
+            val quotedChatMessageView = binding
+                .messageInputView
+                .findViewById<RelativeLayout>(R.id.quotedChatMessageView)
+            quotedChatMessageView?.tag = jsonMessageId
+            quotedChatMessageView?.visibility = View.VISIBLE
+        }
+    }
+
     private fun setMessageAsDeleted(message: IMessage?) {
         val messageTemp = message as ChatMessage
         messageTemp.isDeleted = true

+ 37 - 0
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 <shainsingh89@gmail.com>
+ * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ * 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)
+}

+ 300 - 0
app/src/main/java/com/nextcloud/talk/ui/recyclerview/MessageSwipeCallback.kt

@@ -0,0 +1,300 @@
+/*
+ * Nextcloud Talk application
+ *
+ * @author Shain Singh
+ * @author Andy Scherzinger
+ * Copyright (C) 2021 Shain Singh <shainsingh89@gmail.com>
+ * Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ * 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() {
+
+    private var density = DENSITY_DEFAULT
+
+    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 = NO_PROGRESS
+    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 NO_SWIPE_FLAG
+    }
+
+    override fun onMove(
+        recyclerView: RecyclerView,
+        viewHolder: RecyclerView.ViewHolder,
+        target: RecyclerView.ViewHolder
+    ): Boolean {
+        return false
+    }
+
+    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+        // unused atm
+    }
+
+    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(SWIPE_LIMIT) || 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(REPLY_POINT)) {
+                    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(MIN_ANIMATION_TIME_IN_MILLIS, newTime - lastReplyButtonAnimationTime)
+        lastReplyButtonAnimationTime = newTime
+        val showing = translationX >= convertToDp(SHOW_REPLY_ICON_POINT)
+        if (showing) {
+            if (replyButtonProgress < FULL_PROGRESS) {
+                replyButtonProgress += dt / PROGRESS_CALCULATION_TIME_BASE
+                if (replyButtonProgress > FULL_PROGRESS) {
+                    replyButtonProgress = FULL_PROGRESS
+                } else {
+                    view.invalidate()
+                }
+            }
+        } else if (translationX <= NO_PROGRESS) {
+            replyButtonProgress = NO_PROGRESS
+            startTracking = false
+            isVibrate = false
+        } else {
+            if (replyButtonProgress > NO_PROGRESS) {
+                replyButtonProgress -= dt / PROGRESS_CALCULATION_TIME_BASE
+                if (replyButtonProgress < PROGRESS_THRESHOLD) {
+                    replyButtonProgress = NO_PROGRESS
+                } else {
+                    view.invalidate()
+                }
+            }
+        }
+
+        val alpha: Int
+        val scale: Float
+        if (showing) {
+            scale = if (replyButtonProgress <= SCALE_PROGRESS_TOP_THRESHOLD) {
+                SCALE_PROGRESS_MULTIPLIER * (replyButtonProgress / SCALE_PROGRESS_TOP_THRESHOLD)
+            } else {
+                SCALE_PROGRESS_MULTIPLIER -
+                    SCALE_PROGRESS_BOTTOM_THRESHOLD *
+                    ((replyButtonProgress - SCALE_PROGRESS_TOP_THRESHOLD) / SCALE_PROGRESS_BOTTOM_THRESHOLD)
+            }
+            alpha = min(FULLY_OPAQUE, FULLY_OPAQUE * (replyButtonProgress / SCALE_PROGRESS_TOP_THRESHOLD)).toInt()
+        } else {
+            scale = replyButtonProgress
+            alpha = min(FULLY_OPAQUE, FULLY_OPAQUE * replyButtonProgress).toInt()
+        }
+
+        if (startTracking && !isVibrate && view.translationX >= convertToDp(REPLY_POINT)) {
+            view.performHapticFeedback(
+                HapticFeedbackConstants.KEYBOARD_TAP,
+                HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
+            )
+            isVibrate = true
+        }
+
+        drawReplyIcon(alpha, scale, canvas)
+    }
+
+    private fun drawReplyIcon(alpha: Int, scale: Float, canvas: Canvas) {
+        val x: Int = if (view.translationX > convertToDp(SWIPE_LIMIT)) {
+            convertToDp(SWIPE_LIMIT) / AXIS_BASE
+        } else {
+            (view.translationX / AXIS_BASE).toInt()
+        }
+
+        val y = (view.top + view.measuredHeight / AXIS_BASE).toFloat()
+
+        shareRound.alpha = alpha
+        imageDrawable.alpha = alpha
+
+        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(BACKGROUND_BOUNDS_PIXEL) * scale).toInt(),
+            (y - convertToDp(BACKGROUND_BOUNDS_PIXEL) * scale).toInt(),
+            (x + convertToDp(BACKGROUND_BOUNDS_PIXEL) * scale).toInt(),
+            (y + convertToDp(BACKGROUND_BOUNDS_PIXEL) * scale).toInt()
+        )
+        shareRound.draw(canvas)
+
+        imageDrawable.setBounds(
+            (x - convertToDp(ICON_BOUNDS_PIXEL_LEFT) * scale).toInt(),
+            (y - convertToDp(ICON_BOUNDS_PIXEL_TOP) * scale).toInt(),
+            (x + convertToDp(ICON_BOUNDS_PIXEL_RIGHT) * scale).toInt(),
+            (y + convertToDp(ICON_BOUNDS_PIXEL_BOTTOM) * scale).toInt()
+        )
+        imageDrawable.draw(canvas)
+
+        shareRound.alpha = FULLY_OPAQUE_INT
+        imageDrawable.alpha = FULLY_OPAQUE_INT
+    }
+
+    private fun convertToDp(pixel: Int): Int {
+        return dp(pixel.toFloat(), context)
+    }
+
+    private fun dp(value: Float, context: Context): Int {
+        if (density == DENSITY_DEFAULT) {
+            checkDisplaySize(context)
+        }
+        return if (value == DENSITY_ZERO) {
+            DENSITY_ZERO_INT
+        } else {
+            ceil((density * value).toDouble()).toInt()
+        }
+    }
+
+    @Suppress("Detekt.TooGenericExceptionCaught")
+    private fun checkDisplaySize(context: Context) {
+        try {
+            density = context.resources.displayMetrics.density
+        } catch (e: Exception) {
+            Log.w(TAG, "Error calculating density", e)
+        }
+    }
+
+    companion object {
+        const val TAG = "MessageSwipeCallback"
+        const val NO_SWIPE_FLAG: Int = 0
+        const val FULLY_OPAQUE: Float = 255f
+        const val FULLY_OPAQUE_INT: Int = 255
+        const val DENSITY_DEFAULT: Float = 1f
+        const val DENSITY_ZERO: Float = 0f
+        const val DENSITY_ZERO_INT: Int = 0
+        const val REPLY_POINT: Int = 100
+        const val SWIPE_LIMIT: Int = 130
+        const val SHOW_REPLY_ICON_POINT: Int = 30
+        const val MIN_ANIMATION_TIME_IN_MILLIS: Long = 17
+        const val FULL_PROGRESS: Float = 1.0f
+        const val NO_PROGRESS: Float = 0.0f
+        const val PROGRESS_THRESHOLD: Float = 0.1f
+        const val PROGRESS_CALCULATION_TIME_BASE: Float = 180.0f
+        const val SCALE_PROGRESS_MULTIPLIER: Float = 1.2f
+        const val SCALE_PROGRESS_TOP_THRESHOLD: Float = 0.8f
+        const val SCALE_PROGRESS_BOTTOM_THRESHOLD: Float = 0.2f
+        const val AXIS_BASE: Int = 2
+        const val BACKGROUND_BOUNDS_PIXEL: Int = 18
+        const val ICON_BOUNDS_PIXEL_LEFT: Int = 12
+        const val ICON_BOUNDS_PIXEL_TOP: Int = 13
+        const val ICON_BOUNDS_PIXEL_RIGHT: Int = 12
+        const val ICON_BOUNDS_PIXEL_BOTTOM: Int = 11
+    }
+}

+ 1 - 1
detekt.yml

@@ -1,5 +1,5 @@
 build:
-  maxIssues: 201
+  maxIssues: 202
   weights:
     # complexity: 2
     # LongParameterList: 1