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 ### Added
- Reply to chat message feature (with original message preview) - Reply to chat message feature (with original message preview)
- Swipe action on chat messages to reply / delete
- Voice recordings in chat feature - Voice recordings in chat feature
- Allow video recording in chat file sharing - Allow video recording in chat file sharing
- Unread messages indicator in chat conversation that separates read & unread messages - 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 newItem: EventLogData
): Boolean { ): Boolean {
return if (newItem.eventLog.type == EventLog.Type.ConferenceChatMessage) { 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 } else true
} }
} }

View file

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

View file

@ -29,6 +29,7 @@ import android.provider.MediaStore
import android.view.* import android.view.*
import android.view.ViewTreeObserver.OnGlobalLayoutListener import android.view.ViewTreeObserver.OnGlobalLayoutListener
import android.widget.PopupWindow import android.widget.PopupWindow
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.doOnPreDraw import androidx.core.view.doOnPreDraw
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
@ -226,32 +227,48 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
} }
) )
if (corePreferences.allowSwipeActionOnChatMessage) { // Swipe action
// Swipe action val swipeConfiguration = RecyclerViewSwipeConfiguration()
val swipeConfiguration = RecyclerViewSwipeConfiguration() // Reply action can only be done on a ChatMessageEventLog
swipeConfiguration.leftToRightAction = RecyclerViewSwipeConfiguration.Action( swipeConfiguration.leftToRightAction = RecyclerViewSwipeConfiguration.Action(
icon = R.drawable.menu_reply, text = requireContext().getString(R.string.chat_message_context_menu_reply),
preventFor = ChatMessagesListAdapter.EventViewHolder::class.java backgroundColor = ContextCompat.getColor(requireContext(), R.color.light_grey_color),
) preventFor = ChatMessagesListAdapter.EventViewHolder::class.java
val swipeListener = object : RecyclerViewSwipeListener { )
override fun onLeftToRightSwipe(viewHolder: RecyclerView.ViewHolder) { // Delete action can be done on any EventLog
adapter.notifyItemChanged(viewHolder.bindingAdapterPosition) 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 chatMessageEventLog = adapter.currentList[viewHolder.bindingAdapterPosition]
val chatMessage = chatMessageEventLog.eventLog.chatMessage val chatMessage = chatMessageEventLog.eventLog.chatMessage
if (chatMessage != null) { if (chatMessage != null) {
chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy() chatSendingViewModel.pendingChatMessageToReplyTo.value?.destroy()
chatSendingViewModel.pendingChatMessageToReplyTo.value = chatSendingViewModel.pendingChatMessageToReplyTo.value =
ChatMessageData(chatMessage) ChatMessageData(chatMessage)
chatSendingViewModel.isPendingAnswer.value = true 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) { val chatScrollListener = object : ChatScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int) { override fun onLoadMore(totalItemsCount: Int) {

View file

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

View file

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

View file

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

View file

@ -84,26 +84,38 @@ private class RecyclerViewSwipeUtilsCallback(
background.draw(canvas) background.draw(canvas)
} }
val iconHorizontalMargin: Int = TypedValue.applyDimension( val horizontalMargin: Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, TypedValue.COMPLEX_UNIT_DIP,
configuration.iconMargin, configuration.iconMargin,
recyclerView.context.resources.displayMetrics recyclerView.context.resources.displayMetrics
).toInt() ).toInt()
var iconSize = 0 var iconWidth = 0
if (configuration.leftToRightAction.icon != 0 && dX > iconHorizontalMargin) { if (configuration.leftToRightAction.icon != 0) {
val icon = val icon =
ContextCompat.getDrawable(recyclerView.context, configuration.leftToRightAction.icon) ContextCompat.getDrawable(
if (icon != null) { recyclerView.context,
iconSize = icon.intrinsicHeight configuration.leftToRightAction.icon
val halfIcon = iconSize / 2 )
iconWidth = icon?.intrinsicWidth ?: 0
if (icon != null && dX > iconWidth) {
val halfIcon = icon.intrinsicHeight / 2
val top = val top =
viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon) 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( icon.setBounds(
viewHolder.itemView.left + iconHorizontalMargin, left,
top, top,
viewHolder.itemView.left + iconHorizontalMargin + icon.intrinsicWidth, left + iconWidth,
top + icon.intrinsicHeight 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() val textPaint = TextPaint()
textPaint.isAntiAlias = true textPaint.isAntiAlias = true
textPaint.textSize = TypedValue.applyDimension( textPaint.textSize = TypedValue.applyDimension(
@ -127,9 +139,9 @@ private class RecyclerViewSwipeUtilsCallback(
textPaint.color = configuration.leftToRightAction.textColor textPaint.color = configuration.leftToRightAction.textColor
textPaint.typeface = configuration.actionTextFont textPaint.typeface = configuration.actionTextFont
val margin = if (iconSize > 0) iconHorizontalMargin / 2 else 0 val margin = if (iconWidth > 0) horizontalMargin / 2 else 0
val textX = val textX =
(viewHolder.itemView.left + iconHorizontalMargin + iconSize + margin).toFloat() (viewHolder.itemView.left + horizontalMargin + iconWidth + margin).toFloat()
val textY = val textY =
(viewHolder.itemView.top + (viewHolder.itemView.bottom - viewHolder.itemView.top) / 2.0 + textPaint.textSize / 2).toFloat() (viewHolder.itemView.top + (viewHolder.itemView.bottom - viewHolder.itemView.top) / 2.0 + textPaint.textSize / 2).toFloat()
canvas.drawText( canvas.drawText(
@ -158,30 +170,44 @@ private class RecyclerViewSwipeUtilsCallback(
background.draw(canvas) background.draw(canvas)
} }
val iconHorizontalMargin: Int = TypedValue.applyDimension( val horizontalMargin: Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, TypedValue.COMPLEX_UNIT_DIP,
configuration.iconMargin, configuration.iconMargin,
recyclerView.context.resources.displayMetrics recyclerView.context.resources.displayMetrics
).toInt() ).toInt()
var iconSize = 0 var iconWidth = 0
var imageLeftBorder = viewHolder.itemView.right var imageLeftBorder = viewHolder.itemView.right
if (configuration.rightToLeftAction.icon != 0 && dX < -iconHorizontalMargin) { if (configuration.rightToLeftAction.icon != 0) {
val icon = val icon =
ContextCompat.getDrawable(recyclerView.context, configuration.rightToLeftAction.icon) ContextCompat.getDrawable(
if (icon != null) { recyclerView.context,
iconSize = icon.intrinsicHeight configuration.rightToLeftAction.icon
val halfIcon = iconSize / 2 )
iconWidth = icon?.intrinsicWidth ?: 0
if (icon != null && dX < viewHolder.itemView.right - iconWidth) {
val halfIcon = icon.intrinsicHeight / 2
val top = val top =
viewHolder.itemView.top + ((viewHolder.itemView.bottom - viewHolder.itemView.top) / 2 - halfIcon) 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( icon.setBounds(
imageLeftBorder, imageLeftBorder,
top, top,
viewHolder.itemView.right - iconHorizontalMargin, right,
top + icon.intrinsicHeight top + icon.intrinsicHeight
) )
@Suppress("DEPRECATION")
if (configuration.rightToLeftAction.iconTint != 0) icon.setColorFilter( if (configuration.rightToLeftAction.iconTint != 0) icon.setColorFilter(
configuration.rightToLeftAction.iconTint, configuration.rightToLeftAction.iconTint,
PorterDuff.Mode.SRC_IN PorterDuff.Mode.SRC_IN
@ -189,7 +215,8 @@ private class RecyclerViewSwipeUtilsCallback(
icon.draw(canvas) icon.draw(canvas)
} }
} }
if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -iconHorizontalMargin - iconSize) {
if (configuration.rightToLeftAction.text.isNotEmpty() && dX < -horizontalMargin - iconWidth) {
val textPaint = TextPaint() val textPaint = TextPaint()
textPaint.isAntiAlias = true textPaint.isAntiAlias = true
textPaint.textSize = TypedValue.applyDimension( textPaint.textSize = TypedValue.applyDimension(
@ -201,7 +228,7 @@ private class RecyclerViewSwipeUtilsCallback(
textPaint.typeface = configuration.actionTextFont textPaint.typeface = configuration.actionTextFont
val margin = val margin =
if (imageLeftBorder == viewHolder.itemView.right) iconHorizontalMargin else iconHorizontalMargin / 2 if (imageLeftBorder == viewHolder.itemView.right) horizontalMargin else horizontalMargin / 2
val textX = val textX =
imageLeftBorder - textPaint.measureText(configuration.rightToLeftAction.text) - margin imageLeftBorder - textPaint.measureText(configuration.rightToLeftAction.text) - margin
val textY = val textY =
@ -239,6 +266,8 @@ private class RecyclerViewSwipeUtilsCallback(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder viewHolder: RecyclerView.ViewHolder
): Int { ): 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 var dirFlags = direction
if (direction and ItemTouchHelper.RIGHT != 0) { if (direction and ItemTouchHelper.RIGHT != 0) {
val classToPrevent = configuration.leftToRightAction.preventFor 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( override fun onChildDraw(
canvas: Canvas, canvas: Canvas,
recyclerView: RecyclerView, recyclerView: RecyclerView,

View file

@ -37,27 +37,27 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="35dp"
android:layout_marginTop="5dp" android:layout_marginTop="5dp"
android:layout_marginBottom="5dp" android:layout_marginBottom="5dp"
android:layout_toLeftOf="@id/select" 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:background="@{data.security || data.groupLeft ? @drawable/event_decoration_red : @drawable/event_decoration_gray, default=@drawable/event_decoration_gray}"
android:gravity="center"
android:orientation="horizontal"> android:orientation="horizontal">
<!-- Ugly hack to prevent last character to be missing half of it, don't know why yet --> <!-- Ugly hack to prevent last character to be missing half of it, don't know why yet -->
<TextView <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_width="wrap_content"
android:layout_height="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: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> </LinearLayout>

View file

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