diff --git a/CHANGES.md b/CHANGES.md
index d1e84259..5b598a57 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -7,6 +7,8 @@ Features:
Improvements:
- Handle click on redacted events: view source and create permalink
+ - Improve long tap menu: reply on top, more compact (#368)
+ - Quick reply in timeline with swipe gesture
- Improve edit of replies
Other changes:
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
index c1833cd0..e0f67aaf 100644
--- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
@@ -37,9 +37,11 @@ import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProviders
+import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView
+import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
@@ -57,6 +59,7 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.*
+import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
@@ -88,7 +91,7 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.action.*
import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
-import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
+import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.html.PillImageSpan
import im.vector.riotx.features.invite.VectorInviteView
@@ -324,6 +327,32 @@ class RoomDetailFragment :
})
recyclerView.setController(timelineEventController)
timelineEventController.callback = this
+
+ if (VectorPreferences.swipeToReplyIsEnabled(requireContext())) {
+ val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
+ R.drawable.ic_reply,
+ object : RoomMessageTouchHelperCallback.QuickReplayHandler {
+ override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
+ (model as? AbsMessageItem)?.informationData?.let {
+ val eventId = it.eventId
+ roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
+ }
+ }
+
+ override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
+ return when (model) {
+ is MessageFileItem,
+ is MessageImageVideoItem,
+ is MessageTextItem -> {
+ return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
+ }
+ else -> false
+ }
+ }
+ })
+ val touchHelper = ItemTouchHelper(swipeCallback)
+ touchHelper.attachToRecyclerView(recyclerView)
+ }
}
private fun setupComposer() {
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt
new file mode 100644
index 00000000..2803c66b
--- /dev/null
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt
@@ -0,0 +1,206 @@
+/*
+ * Copyright 2019 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package im.vector.riotx.features.home.room.detail
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import android.util.TypedValue
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.View
+import androidx.annotation.DrawableRes
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.ACTION_STATE_SWIPE
+import androidx.recyclerview.widget.RecyclerView
+import com.airbnb.epoxy.EpoxyModel
+import com.airbnb.epoxy.EpoxyTouchHelperCallback
+import com.airbnb.epoxy.EpoxyViewHolder
+import timber.log.Timber
+
+
+class RoomMessageTouchHelperCallback(private val context: Context,
+ @DrawableRes actionIcon: Int,
+ private val handler: QuickReplayHandler) : EpoxyTouchHelperCallback() {
+
+ interface QuickReplayHandler {
+ fun performQuickReplyOnHolder(model: EpoxyModel<*>)
+ fun canSwipeModel(model: EpoxyModel<*>): Boolean
+ }
+
+ private var swipeBack: Boolean = false
+ private var dX = 0f
+ private var startTracking = false
+ private var isVibrate = false
+
+ private var replyButtonProgress: Float = 0F
+ private var lastReplyButtonAnimationTime: Long = 0
+
+ private var imageDrawable: Drawable = ContextCompat.getDrawable(context, actionIcon)!!
+
+
+ private val triggerDistance = convertToPx(100)
+ private val minShowDistance = convertToPx(20)
+ private val triggerDelta = convertToPx(20)
+
+ override fun onSwiped(viewHolder: EpoxyViewHolder?, direction: Int) {
+
+ }
+
+ override fun onMove(recyclerView: RecyclerView?, viewHolder: EpoxyViewHolder?, target: EpoxyViewHolder?): Boolean {
+ return false
+ }
+
+ override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: EpoxyViewHolder): Int {
+ if (handler.canSwipeModel(viewHolder.model)) {
+ return ItemTouchHelper.Callback.makeMovementFlags(0, ItemTouchHelper.START) //Should we use Left?
+ } else {
+ return 0
+ }
+ }
+
+
+ //We never let items completely go out
+ 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: EpoxyViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
+ if (actionState == ACTION_STATE_SWIPE) {
+ setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+ }
+ val size = triggerDistance
+ if (Math.abs(viewHolder.itemView.translationX) < size || dX > this.dX /*going back*/) {
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ this.dX = dX
+ startTracking = true
+ }
+ drawReplyButton(c, viewHolder.itemView)
+ }
+
+
+ @SuppressLint("ClickableViewAccessibility")
+ private fun setTouchListener(c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: EpoxyViewHolder,
+ dX: Float, dY: Float,
+ actionState: Int, isCurrentlyActive: Boolean) {
+ //TODO can this interfer with other interactions? should i remove it
+ recyclerView.setOnTouchListener { v, event ->
+ swipeBack = event.action == MotionEvent.ACTION_CANCEL || event.action == MotionEvent.ACTION_UP
+ if (swipeBack) {
+ if (Math.abs(dX) >= triggerDistance) {
+ try {
+ viewHolder.model?.let { handler.performQuickReplyOnHolder(it) }
+ } catch (e: IllegalStateException) {
+ Timber.e(e)
+ }
+ }
+ }
+ false
+ }
+ }
+
+
+ private fun drawReplyButton(canvas: Canvas, itemView: View) {
+
+ Timber.v("drawReplyButton")
+ val translationX = Math.abs(itemView.translationX)
+ val newTime = System.currentTimeMillis()
+ val dt = Math.min(17, newTime - lastReplyButtonAnimationTime)
+ lastReplyButtonAnimationTime = newTime
+ val showing = translationX >= minShowDistance
+ if (showing) {
+ if (replyButtonProgress < 1.0f) {
+ replyButtonProgress += dt / 180.0f
+ if (replyButtonProgress > 1.0f) {
+ replyButtonProgress = 1.0f
+ } else {
+ itemView.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 {
+ itemView.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 = Math.min(255f, 255 * (replyButtonProgress / 0.8f)).toInt()
+ } else {
+ scale = replyButtonProgress
+ alpha = Math.min(255f, 255 * replyButtonProgress).toInt()
+ }
+
+ imageDrawable.alpha = alpha
+ if (startTracking) {
+ if (!isVibrate && translationX >= triggerDistance) {
+ itemView.performHapticFeedback(
+ HapticFeedbackConstants.LONG_PRESS
+// , HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
+ )
+ isVibrate = true
+ }
+ }
+
+ val x: Int = itemView.width - if (translationX > triggerDistance + triggerDelta) {
+ (convertToPx(130) / 2).toInt()
+ } else {
+ (translationX / 2).toInt()
+ }
+
+ val y = (itemView.top + itemView.measuredHeight / 2).toFloat()
+ //magic numbers?
+ imageDrawable.setBounds(
+ (x - convertToPx(12) * scale).toInt(),
+ (y - convertToPx(11) * scale).toInt(),
+ (x + convertToPx(12) * scale).toInt(),
+ (y + convertToPx(10) * scale).toInt()
+ )
+ imageDrawable.draw(canvas)
+ imageDrawable.alpha = 255
+ }
+
+ private fun convertToPx(dp: Int): Float {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
index dc7c926d..54ff3077 100644
--- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
@@ -127,13 +127,6 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
//TODO is downloading attachement?
if (!event.root.isRedacted()) {
- if (event.canReact()) {
- this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
- }
- if (canCopy(type)) {
- //TODO copy images? html? see ClipBoard
- this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
- }
if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
@@ -147,6 +140,15 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, eventId))
}
+ if (canCopy(type)) {
+ //TODO copy images? html? see ClipBoard
+ this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
+ }
+
+ if (event.canReact()) {
+ this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
+ }
+
if (canQuote(event, messageContent)) {
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
}
diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
index 00f66f7c..b607b5a6 100755
--- a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
+++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt
@@ -148,6 +148,7 @@ object VectorPreferences {
private const val SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY = "SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"
private const val SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY = "SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
+ private const val SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY = "SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"
// analytics
const val SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY"
@@ -249,6 +250,10 @@ object VectorPreferences {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY, false)
}
+ fun swipeToReplyIsEnabled(context: Context): Boolean {
+ return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY, true)
+ }
+
/**
* Tells if we have already asked the user to disable battery optimisations on android >= M devices.
*
diff --git a/vector/src/main/res/layout/adapter_item_action_quick_reaction.xml b/vector/src/main/res/layout/adapter_item_action_quick_reaction.xml
index 9f26e95f..16a53cff 100644
--- a/vector/src/main/res/layout/adapter_item_action_quick_reaction.xml
+++ b/vector/src/main/res/layout/adapter_item_action_quick_reaction.xml
@@ -7,18 +7,6 @@
android:layout_height="wrap_content"
android:padding="8dp">
-
-
-
+ app:layout_constraintTop_toTopOf="parent" />
diff --git a/vector/src/main/res/layout/bottom_sheet_message_actions.xml b/vector/src/main/res/layout/bottom_sheet_message_actions.xml
index d1cb8c9f..9fadcee1 100644
--- a/vector/src/main/res/layout/bottom_sheet_message_actions.xml
+++ b/vector/src/main/res/layout/bottom_sheet_message_actions.xml
@@ -28,6 +28,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" />
Other third party notices
You are already viewing this room!
- Quick Reactions
General
diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml
index aa24261e..a777e85f 100644
--- a/vector/src/main/res/values/strings_riotX.xml
+++ b/vector/src/main/res/values/strings_riotX.xml
@@ -35,4 +35,5 @@
Name or ID (#example:matrix.org)
+ Enable swipe to reply in timeline
\ No newline at end of file
diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml
index 66eb3f39..56205172 100644
--- a/vector/src/main/res/xml/vector_settings_labs.xml
+++ b/vector/src/main/res/xml/vector_settings_labs.xml
@@ -1,45 +1,49 @@
-
+
-
-
+
+
-
+
-
-
-
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
+
+
+
+