Added swipe actions on chat message (reply / delete), improved chat message redraw condition

This commit is contained in:
Sylvain Berfini 2022-01-10 16:03:01 +01:00
parent 0378848f10
commit 9963381419
10 changed files with 135 additions and 75 deletions

View file

@ -14,6 +14,7 @@ Group changes to describe their impact on the project, as follows:
### Added
- Reply to chat message feature (with original message preview)
- Swipe action on chat messages to reply / delete
- Voice recordings in chat feature
- Allow video recording in chat file sharing
- Unread messages indicator in chat conversation that separates read & unread messages

View file

@ -452,7 +452,11 @@ private class ChatMessageDiffCallback : DiffUtil.ItemCallback<EventLogData>() {
newItem: EventLogData
): Boolean {
return if (newItem.eventLog.type == EventLog.Type.ConferenceChatMessage) {
newItem.eventLog.chatMessage?.state == ChatMessage.State.Displayed
val oldData = (oldItem.data as ChatMessageData)
val newData = (newItem.data as ChatMessageData)
val previous = oldData.hasPreviousMessage == newData.hasPreviousMessage
val next = oldData.hasNextMessage == newData.hasNextMessage
newItem.eventLog.chatMessage?.state == ChatMessage.State.Displayed && previous && next
} else true
}
}

View file

@ -59,6 +59,9 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes
val replyData = MutableLiveData<ChatMessageData>()
var hasPreviousMessage = false
var hasNextMessage = false
private var countDownTimer: CountDownTimer? = null
private val listener = object : ChatMessageListenerStub() {
@ -106,6 +109,11 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes
}
fun updateBubbleBackground(hasPrevious: Boolean, hasNext: Boolean) {
hasPreviousMessage = hasPrevious
hasNextMessage = hasNext
hideTime.value = false
hideAvatar.value = false
if (hasPrevious) {
hideTime.value = true
}

View file

@ -29,6 +29,7 @@ import android.provider.MediaStore
import android.view.*
import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.doOnPreDraw
import androidx.databinding.DataBindingUtil
@ -226,32 +227,48 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
}
)
if (corePreferences.allowSwipeActionOnChatMessage) {
// Swipe action
val swipeConfiguration = RecyclerViewSwipeConfiguration()
swipeConfiguration.leftToRightAction = RecyclerViewSwipeConfiguration.Action(
icon = R.drawable.menu_reply,
preventFor = ChatMessagesListAdapter.EventViewHolder::class.java
)
val swipeListener = object : RecyclerViewSwipeListener {
override fun onLeftToRightSwipe(viewHolder: RecyclerView.ViewHolder) {
adapter.notifyItemChanged(viewHolder.bindingAdapterPosition)
// Swipe action
val swipeConfiguration = RecyclerViewSwipeConfiguration()
// Reply action can only be done on a ChatMessageEventLog
swipeConfiguration.leftToRightAction = RecyclerViewSwipeConfiguration.Action(
text = requireContext().getString(R.string.chat_message_context_menu_reply),
backgroundColor = ContextCompat.getColor(requireContext(), R.color.light_grey_color),
preventFor = ChatMessagesListAdapter.EventViewHolder::class.java
)
// Delete action can be done on any EventLog
swipeConfiguration.rightToLeftAction = RecyclerViewSwipeConfiguration.Action(
text = requireContext().getString(R.string.chat_message_context_menu_delete),
backgroundColor = ContextCompat.getColor(requireContext(), R.color.red_color)
)
val swipeListener = object : RecyclerViewSwipeListener {
override fun onLeftToRightSwipe(viewHolder: RecyclerView.ViewHolder) {
adapter.notifyItemChanged(viewHolder.bindingAdapterPosition)
val chatMessageEventLog = adapter.currentList[viewHolder.bindingAdapterPosition]
val chatMessage = chatMessageEventLog.eventLog.chatMessage
if (chatMessage != null) {
chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy()
chatSendingViewModel.pendingChatMessageToReplyTo.value =
ChatMessageData(chatMessage)
chatSendingViewModel.isPendingAnswer.value = true
}
val chatMessageEventLog = adapter.currentList[viewHolder.bindingAdapterPosition]
val chatMessage = chatMessageEventLog.eventLog.chatMessage
if (chatMessage != null) {
chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy()
chatSendingViewModel.pendingChatMessageToReplyTo.value =
ChatMessageData(chatMessage)
chatSendingViewModel.isPendingAnswer.value = true
}
}
override fun onRightToLeftSwipe(viewHolder: RecyclerView.ViewHolder) {
val position = viewHolder.bindingAdapterPosition
val eventLog = adapter.currentList[position]
val chatMessage = eventLog.eventLog.chatMessage
if (chatMessage != null) {
Log.i("[Chat Room] Deleting message $chatMessage at position $position")
listViewModel.deleteMessage(chatMessage)
} else {
Log.i("[Chat Room] Deleting event $eventLog at position $position")
listViewModel.deleteEventLogs(arrayListOf(eventLog))
}
override fun onRightToLeftSwipe(viewHolder: RecyclerView.ViewHolder) {}
}
RecyclerViewSwipeUtils(ItemTouchHelper.RIGHT, swipeConfiguration, swipeListener)
.attachToRecyclerView(binding.chatMessagesList)
}
RecyclerViewSwipeUtils(ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT, swipeConfiguration, swipeListener)
.attachToRecyclerView(binding.chatMessagesList)
val chatScrollListener = object : ChatScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int) {

View file

@ -158,27 +158,21 @@ class ChatMessagesListViewModel(private val chatRoom: ChatRoom) : ViewModel() {
}
fun deleteMessage(chatMessage: ChatMessage) {
val position: Int = chatMessage.userData as Int
LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage)
chatRoom.deleteMessage(chatMessage)
val list = arrayListOf<EventLogData>()
list.addAll(events.value.orEmpty())
list.removeAt(position)
events.value = list
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
fun deleteEventLogs(listToDelete: ArrayList<EventLogData>) {
val list = arrayListOf<EventLogData>()
list.addAll(events.value.orEmpty())
for (eventLog in listToDelete) {
LinphoneUtils.deleteFilesAttachedToEventLog(eventLog.eventLog)
eventLog.eventLog.deleteFromDatabase()
list.remove(eventLog)
}
events.value = list
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
fun loadMoreData(totalItemsCount: Int) {
@ -248,6 +242,8 @@ class ChatMessagesListViewModel(private val chatRoom: ChatRoom) : ViewModel() {
LinphoneUtils.deleteFilesAttachedToChatMessage(chatMessage)
chatRoom.deleteMessage(chatMessage)
}
events.value.orEmpty().forEach(EventLogData::destroy)
events.value = getEvents()
}
}

View file

@ -113,7 +113,7 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf
override fun onContactsUpdated() {
Log.i("[Chat Room] Contacts have changed")
contactLookup()
lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory)
updateLastMessageToDisplay()
}
}
@ -199,7 +199,7 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf
override fun onEphemeralMessageDeleted(chatRoom: ChatRoom, eventLog: EventLog) {
Log.i("[Chat Room] Ephemeral message deleted, updated last message displayed")
lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory)
updateLastMessageToDisplay()
}
override fun onEphemeralEvent(chatRoom: ChatRoom, eventLog: EventLog) {
@ -226,7 +226,7 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf
contactLookup()
updateParticipants()
lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory)
updateLastMessageToDisplay()
callInProgress.value = chatRoom.core.callsNb > 0
updateRemotesComposing()
@ -276,6 +276,10 @@ class ChatRoomViewModel(val chatRoom: ChatRoom) : ViewModel(), ContactDataInterf
}
}
private fun updateLastMessageToDisplay() {
lastMessageText.value = formatLastMessage(chatRoom.lastMessageInHistory)
}
private fun formatLastMessage(msg: ChatMessage?): String {
if (msg == null) return ""

View file

@ -465,9 +465,6 @@ class CorePreferences constructor(private val context: Context) {
val showAllRingtones: Boolean
get() = config.getBool("app", "show_all_available_ringtones", false)
val allowSwipeActionOnChatMessage: Boolean
get() = config.getBool("app", "swipe_action_on_chat_messages", false)
/* Default values related */
val echoCancellerCalibration: Int

View file

@ -84,26 +84,38 @@ private class RecyclerViewSwipeUtilsCallback(
background.draw(canvas)
}
val iconHorizontalMargin: Int = TypedValue.applyDimension(
val horizontalMargin: Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
configuration.iconMargin,
recyclerView.context.resources.displayMetrics
).toInt()
var iconSize = 0
var iconWidth = 0
if (configuration.leftToRightAction.icon != 0 && dX > iconHorizontalMargin) {
if (configuration.leftToRightAction.icon != 0) {
val icon =
ContextCompat.getDrawable(recyclerView.context, configuration.leftToRightAction.icon)
if (icon != null) {
iconSize = icon.intrinsicHeight
val halfIcon = iconSize / 2
ContextCompat.getDrawable(
recyclerView.context,
configuration.leftToRightAction.icon
)
iconWidth = icon?.intrinsicWidth ?: 0
if (icon != null && dX > iconWidth) {
val halfIcon = icon.intrinsicHeight / 2
val top =
viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon)
// Icon won't move past the swipe threshold, thus indicating to the user
// it has reached the required distance for swipe action to be done
val threshold = getSwipeThreshold(viewHolder) * viewHolder.itemView.right
val left = if (dX < threshold) {
viewHolder.itemView.left + dX.toInt() - iconWidth
} else {
viewHolder.itemView.left + threshold.toInt() - iconWidth
}
icon.setBounds(
viewHolder.itemView.left + iconHorizontalMargin,
left,
top,
viewHolder.itemView.left + iconHorizontalMargin + icon.intrinsicWidth,
left + iconWidth,
top + icon.intrinsicHeight
)
@ -116,7 +128,7 @@ private class RecyclerViewSwipeUtilsCallback(
}
}
if (configuration.leftToRightAction.text.isNotEmpty() && dX > iconHorizontalMargin + iconSize) {
if (configuration.leftToRightAction.text.isNotEmpty() && dX > horizontalMargin + iconWidth) {
val textPaint = TextPaint()
textPaint.isAntiAlias = true
textPaint.textSize = TypedValue.applyDimension(
@ -127,9 +139,9 @@ private class RecyclerViewSwipeUtilsCallback(
textPaint.color = configuration.leftToRightAction.textColor
textPaint.typeface = configuration.actionTextFont
val margin = if (iconSize > 0) iconHorizontalMargin / 2 else 0
val margin = if (iconWidth > 0) horizontalMargin / 2 else 0
val textX =
(viewHolder.itemView.left + iconHorizontalMargin + iconSize + margin).toFloat()
(viewHolder.itemView.left + horizontalMargin + iconWidth + margin).toFloat()
val textY =
(viewHolder.itemView.top + (viewHolder.itemView.bottom - viewHolder.itemView.top) / 2.0 + textPaint.textSize / 2).toFloat()
canvas.drawText(
@ -158,30 +170,44 @@ private class RecyclerViewSwipeUtilsCallback(
background.draw(canvas)
}
val iconHorizontalMargin: Int = TypedValue.applyDimension(
val horizontalMargin: Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
configuration.iconMargin,
recyclerView.context.resources.displayMetrics
).toInt()
var iconSize = 0
var iconWidth = 0
var imageLeftBorder = viewHolder.itemView.right
if (configuration.rightToLeftAction.icon != 0 && dX < -iconHorizontalMargin) {
if (configuration.rightToLeftAction.icon != 0) {
val icon =
ContextCompat.getDrawable(recyclerView.context, configuration.rightToLeftAction.icon)
if (icon != null) {
iconSize = icon.intrinsicHeight
val halfIcon = iconSize / 2
ContextCompat.getDrawable(
recyclerView.context,
configuration.rightToLeftAction.icon
)
iconWidth = icon?.intrinsicWidth ?: 0
if (icon != null && dX < viewHolder.itemView.right - iconWidth) {
val halfIcon = icon.intrinsicHeight / 2
val top =
viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon)
imageLeftBorder =
viewHolder.itemView.right - iconHorizontalMargin - halfIcon * 2
// Icon won't move past the swipe threshold, thus indicating to the user
// it has reached the required distance for swipe action to be done
val threshold = -(getSwipeThreshold(viewHolder) * viewHolder.itemView.right)
val right = if (dX > threshold) {
viewHolder.itemView.right + dX.toInt()
} else {
viewHolder.itemView.right + threshold.toInt()
}
imageLeftBorder = right - icon.intrinsicWidth
icon.setBounds(
imageLeftBorder,
top,
viewHolder.itemView.right - iconHorizontalMargin,
right,
top + icon.intrinsicHeight
)
@Suppress("DEPRECATION")
if (configuration.rightToLeftAction.iconTint != 0) icon.setColorFilter(
configuration.rightToLeftAction.iconTint,
PorterDuff.Mode.SRC_IN
@ -189,7 +215,8 @@ private class RecyclerViewSwipeUtilsCallback(
icon.draw(canvas)
}
}
if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -iconHorizontalMargin - iconSize) {
if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -horizontalMargin - iconWidth) {
val textPaint = TextPaint()
textPaint.isAntiAlias = true
textPaint.textSize = TypedValue.applyDimension(
@ -201,7 +228,7 @@ private class RecyclerViewSwipeUtilsCallback(
textPaint.typeface = configuration.actionTextFont
val margin =
if (imageLeftBorder == viewHolder.itemView.right) iconHorizontalMargin else iconHorizontalMargin / 2
if (imageLeftBorder == viewHolder.itemView.right) horizontalMargin else horizontalMargin / 2
val textX =
imageLeftBorder - textPaint.measureText(configuration.rightToLeftAction.text) - margin
val textY =
@ -239,6 +266,8 @@ private class RecyclerViewSwipeUtilsCallback(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
// Prevent swipe actions for a specific ViewHolder class if needed
// Used to allow swipe actions on chat messages but not events
var dirFlags = direction
if (direction and ItemTouchHelper.RIGHT != 0) {
val classToPrevent = configuration.leftToRightAction.preventFor
@ -278,6 +307,10 @@ private class RecyclerViewSwipeUtilsCallback(
}
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return .33f // A third of the screen is required to validate swipe move (default is .5f)
}
override fun onChildDraw(
canvas: Canvas,
recyclerView: RecyclerView,

View file

@ -37,27 +37,27 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="35dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_toLeftOf="@id/select"
android:gravity="center"
android:background="@{data.security || data.groupLeft ? @drawable/event_decoration_red : @drawable/event_decoration_gray, default=@drawable/event_decoration_gray}"
android:gravity="center"
android:orientation="horizontal">
<!-- Ugly hack to prevent last character to be missing half of it, don't know why yet -->
<TextView
android:text="@{data.text + ' '}"
android:textSize="13sp"
android:fontFamily="sans-serif"
android:lineSpacingExtra="0sp"
android:textStyle="italic"
android:textColor="@{data.security || data.groupLeft ? @color/red_color : @color/light_grey_color}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingRight="5dp"
android:background="?attr/backgroundColor"
android:fontFamily="sans-serif"
android:lineSpacingExtra="0sp"
android:paddingLeft="5dp"
android:background="?attr/backgroundColor" />
android:paddingRight="5dp"
android:text="@{data.text + ' '}"
android:textColor="@{data.security || data.groupLeft ? @color/red_color : @color/light_grey_color}"
android:textSize="13sp"
android:textStyle="italic" />
</LinearLayout>

View file

@ -282,12 +282,12 @@
android:layout_height="match_parent"
android:layout_above="@id/footer"
android:layout_below="@+id/top_bar"
android:paddingBottom="20dp"
android:clipToPadding="false"
android:cacheColorHint="@color/transparent_color"
android:choiceMode="multipleChoice"
android:clipToPadding="false"
android:divider="@android:color/transparent"
android:listSelector="@color/transparent_color"
android:paddingBottom="20dp"
android:transcriptMode="normal" />
<TextView