Added swipe actions on chat message (reply / delete), improved chat message redraw condition
This commit is contained in:
parent
0378848f10
commit
9963381419
10 changed files with 135 additions and 75 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue