Merge pull request #382 from vector-im/feature/better_long_tap_menu

Feature/better long tap menu
This commit is contained in:
Benoit Marty 2019-07-17 14:28:51 +02:00 committed by GitHub
commit 4d5bdecec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 292 additions and 57 deletions

View File

@ -7,6 +7,8 @@ Features:


Improvements: Improvements:
- Handle click on redacted events: view source and create permalink - 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 - Improve edit of replies


Other changes: Other changes:

View File

@ -37,9 +37,11 @@ import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView import butterknife.BindView
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.args import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel 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.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.Membership 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.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.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent 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.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.action.* 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.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.EventHtmlRenderer
import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.html.PillImageSpan
import im.vector.riotx.features.invite.VectorInviteView import im.vector.riotx.features.invite.VectorInviteView
@ -324,6 +327,32 @@ class RoomDetailFragment :
}) })
recyclerView.setController(timelineEventController) recyclerView.setController(timelineEventController)
timelineEventController.callback = this 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() { private fun setupComposer() {

View File

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

}

View File

@ -127,13 +127,6 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
//TODO is downloading attachement? //TODO is downloading attachement?


if (!event.root.isRedacted()) { 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)) { if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId)) 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)) 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)) { if (canQuote(event, messageContent)) {
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId)) this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
} }

View File

@ -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_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_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 // analytics
const val SETTINGS_USE_ANALYTICS_KEY = "SETTINGS_USE_ANALYTICS_KEY" 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) 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. * Tells if we have already asked the user to disable battery optimisations on android >= M devices.
* *

View File

@ -7,18 +7,6 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="8dp"> android:padding="8dp">


<TextView
android:id="@+id/quickReactionTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@string/quick_reactions"
android:textColor="?riotx_text_secondary"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent" />


<TextView <TextView
android:id="@+id/quickReaction0" android:id="@+id/quickReaction0"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -104,16 +92,14 @@
android:id="@+id/reactionsFlowHelper" android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
app:constraint_referenced_ids="quickReaction0,quickReaction1,quickReaction2,quickReaction3,quickReaction4,quickReaction5,quickReaction6,quickReaction7" app:constraint_referenced_ids="quickReaction0,quickReaction1,quickReaction2,quickReaction3,quickReaction4,quickReaction5,quickReaction6,quickReaction7"
app:flow_horizontalGap="8dp" app:flow_horizontalGap="0dp"
app:flow_horizontalStyle="spread" app:flow_horizontalStyle="spread"
app:flow_verticalBias="0" app:flow_verticalBias="0"
app:flow_verticalGap="4dp" app:flow_verticalGap="4dp"
app:flow_wrapMode="chain" app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/quickReactionTitle" /> app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -28,6 +28,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
tools:src="@tools:sample/avatars" /> tools:src="@tools:sample/avatars" />


<TextView <TextView

View File

@ -1534,7 +1534,6 @@ Why choose Riot.im?
<string name="settings_other_third_party_notices">Other third party notices</string> <string name="settings_other_third_party_notices">Other third party notices</string>
<string name="navigate_to_room_when_already_in_the_room">You are already viewing this room!</string> <string name="navigate_to_room_when_already_in_the_room">You are already viewing this room!</string>


<string name="quick_reactions">Quick Reactions</string>


<!-- Settings --> <!-- Settings -->
<string name="settings_general_title">General</string> <string name="settings_general_title">General</string>

View File

@ -35,4 +35,5 @@
<string name="room_directory_search_hint">Name or ID (#example:matrix.org)</string> <string name="room_directory_search_hint">Name or ID (#example:matrix.org)</string>




<string name="labs_swipe_to_reply_in_timeline">Enable swipe to reply in timeline</string>
</resources> </resources>

View File

@ -1,45 +1,49 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">


<!--<im.vector.riotx.core.preference.VectorPreferenceCategory--> <!--<im.vector.riotx.core.preference.VectorPreferenceCategory-->
<!--android:key="SETTINGS_LABS_PREFERENCE_KEY"--> <!--android:key="SETTINGS_LABS_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_pref_title">--> <!--android:title="@string/room_settings_labs_pref_title">-->


<im.vector.riotx.core.preference.VectorPreference <im.vector.riotx.core.preference.VectorPreference
android:focusable="false" android:focusable="false"
android:key="labs_warning" android:key="labs_warning"
android:summary="@string/room_settings_labs_warning_message" /> android:summary="@string/room_settings_labs_warning_message" />


<!--<im.vector.riotx.core.preference.VectorSwitchPreference--> <!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY"--> <!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end" />--> <!--android:title="@string/room_settings_labs_end_to_end" />-->


<!--<im.vector.riotx.core.preference.VectorPreference--> <!--<im.vector.riotx.core.preference.VectorPreference-->
<!--android:focusable="false"--> <!--android:focusable="false"-->
<!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY"--> <!--android:key="SETTINGS_ROOM_SETTINGS_LABS_END_TO_END_IS_ACTIVE_PREFERENCE_KEY"-->
<!--android:title="@string/room_settings_labs_end_to_end_is_active" />--> <!--android:title="@string/room_settings_labs_end_to_end_is_active" />-->


<!--<im.vector.riotx.core.preference.VectorSwitchPreference--> <!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY"--> <!--android:key="SETTINGS_DATA_SAVE_MODE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_data_save_mode_summary"--> <!--android:summary="@string/settings_data_save_mode_summary"-->
<!--android:title="@string/settings_data_save_mode" />--> <!--android:title="@string/settings_data_save_mode" />-->


<!--<im.vector.riotx.core.preference.VectorSwitchPreference--> <!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:defaultValue="true"--> <!--android:defaultValue="true"-->
<!--android:key="SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY"--> <!--android:key="SETTINGS_USE_JITSI_CONF_PREFERENCE_KEY"-->
<!--android:title="@string/settings_labs_create_conference_with_jitsi" />--> <!--android:title="@string/settings_labs_create_conference_with_jitsi" />-->


<!--<im.vector.riotx.core.preference.VectorSwitchPreference--> <!--<im.vector.riotx.core.preference.VectorSwitchPreference-->
<!--android:key="SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"--> <!--android:key="SETTINGS_ENABLE_SEND_VOICE_FEATURE_PREFERENCE_KEY"-->
<!--android:summary="@string/settings_labs_enable_send_voice_summary"--> <!--android:summary="@string/settings_labs_enable_send_voice_summary"-->
<!--android:title="@string/settings_labs_enable_send_voice" />--> <!--android:title="@string/settings_labs_enable_send_voice" />-->


<im.vector.riotx.core.preference.VectorSwitchPreference <im.vector.riotx.core.preference.VectorSwitchPreference
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY" android:defaultValue="false"
android:defaultValue="false" android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
android:title="@string/settings_labs_show_hidden_events_in_timeline" /> android:title="@string/settings_labs_show_hidden_events_in_timeline" />


<im.vector.riotx.core.preference.VectorSwitchPreference
android:defaultValue="true"
android:key="SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"
android:title="@string/labs_swipe_to_reply_in_timeline" />


<!--</im.vector.riotx.core.preference.VectorPreferenceCategory>--> <!--</im.vector.riotx.core.preference.VectorPreferenceCategory>-->