diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f338611..16dc18a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt index 7eb6f214c..95f3322dd 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/adapters/ChatMessagesListAdapter.kt @@ -452,7 +452,11 @@ private class ChatMessageDiffCallback : DiffUtil.ItemCallback() { 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 } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt index 43265f98e..7cfcf1ba7 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/data/ChatMessageData.kt @@ -59,6 +59,9 @@ class ChatMessageData(val chatMessage: ChatMessage) : GenericContactData(chatMes val replyData = MutableLiveData() + 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 } diff --git a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt index d6473826b..335c99500 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/fragments/DetailChatRoomFragment.kt @@ -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() - list.addAll(events.value.orEmpty()) - list.removeAt(position) - events.value = list + events.value.orEmpty().forEach(EventLogData::destroy) + events.value = getEvents() } fun deleteEventLogs(listToDelete: ArrayList) { - val list = arrayListOf() - 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() } } diff --git a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt index 396adee3e..d964c0b51 100644 --- a/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/chat/viewmodels/ChatRoomViewModel.kt @@ -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 "" diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index 549d37469..bc83c4e10 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -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 diff --git a/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt b/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt index c1b4d9ff0..52808677a 100644 --- a/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt +++ b/app/src/main/java/org/linphone/utils/RecyclerViewSwipeUtils.kt @@ -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, diff --git a/app/src/main/res/layout/chat_event_list_cell.xml b/app/src/main/res/layout/chat_event_list_cell.xml index 9c0e2dd2c..172ce2d65 100644 --- a/app/src/main/res/layout/chat_event_list_cell.xml +++ b/app/src/main/res/layout/chat_event_list_cell.xml @@ -37,27 +37,27 @@ + 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" /> diff --git a/app/src/main/res/layout/chat_room_detail_fragment.xml b/app/src/main/res/layout/chat_room_detail_fragment.xml index 0947d00fa..1f3bbd365 100644 --- a/app/src/main/res/layout/chat_room_detail_fragment.xml +++ b/app/src/main/res/layout/chat_room_detail_fragment.xml @@ -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" />