Merge pull request #1313 from nextcloud/swipeToReply

Swipe-right to reply
This commit is contained in:
Andy Scherzinger 2021-06-08 23:35:05 +02:00 committed by GitHub
commit 3e17bf31ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 414 additions and 57 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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
}
}

View file

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