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

View File

@ -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() {

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?

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

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_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.
*

View File

@ -7,18 +7,6 @@
android:layout_height="wrap_content"
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
android:id="@+id/quickReaction0"
android:layout_width="wrap_content"
@ -104,16 +92,14 @@
android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp"
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:flow_horizontalGap="8dp"
app:flow_horizontalGap="0dp"
app:flow_horizontalStyle="spread"
app:flow_verticalBias="0"
app:flow_verticalGap="4dp"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/quickReactionTitle" />
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

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

<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="navigate_to_room_when_already_in_the_room">You are already viewing this room!</string>

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

<!-- Settings -->
<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="labs_swipe_to_reply_in_timeline">Enable swipe to reply in timeline</string>
</resources>

View File

@ -1,45 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">

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

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

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

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

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

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

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

<im.vector.riotx.core.preference.VectorSwitchPreference
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
android:defaultValue="false"
android:title="@string/settings_labs_show_hidden_events_in_timeline" />
<im.vector.riotx.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
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>-->